Merge prerequisite changes into Japanese localisation

This commit is contained in:
vorotamoroz committed 2026-11-05 10:03:28 +00:00
commit 49120cf5e0
98 files changed
+8487 -1233

No files matched your search

@@ -17,6 +17,7 @@ export interface ConflictCheckingDependencies {
IConflictService,
"getOptionalConflictCheckMethod" | "queueCheckFor" | "resolve" | "resolveByNewest"
>;
readonly signal: AbortSignal;
readonly conflictProcessQueueCount: ReactiveSource<number>;
readonly cancelActiveResolution: (filename: FilePathWithPrefix) => void;
readonly currentSettings: () => ConflictCheckingSettings;
@@ -27,6 +28,7 @@ export interface ConflictCheckingDependencies {
export interface ConflictCheckingHandlers {
readonly queueCheckForIfOpen: (file: FilePathWithPrefix) => Promise<void>;
readonly queueCheckFor: (file: FilePathWithPrefix) => Promise<void>;
/** Wait for active checks; false means the queue was cancelled during shutdown. */
readonly ensureAllProcessed: () => Promise<boolean>;
}
@@ -45,11 +47,13 @@ export function createConflictCheckingHandlers(dependencies: ConflictCheckingDep
// and automatic merges can use the remaining execution slots.
concurrency: 10,
onProgress(remaining) {
// Accepted checks still update their accounting as they settle.
dependencies.conflictProcessQueueCount.value = remaining;
},
});
const queueCheckForIfOpen = async (file: FilePathWithPrefix): Promise<void> => {
if (dependencies.signal.aborted) return;
const path = file;
if (dependencies.currentSettings().checkConflictOnlyOnOpen) {
const activeFile: FilePath | undefined = dependencies.vault.getActiveFilePath();
@@ -62,7 +66,9 @@ export function createConflictCheckingHandlers(dependencies: ConflictCheckingDep
};
const queueCheckFor = async (file: FilePathWithPrefix): Promise<void> => {
if (dependencies.signal.aborted) return;
const optionalConflictResult = await dependencies.conflict.getOptionalConflictCheckMethod(file);
if (dependencies.signal.aborted) return;
if (optionalConflictResult == true) {
// The conflict has been resolved by another process.
return;
@@ -79,6 +85,16 @@ export function createConflictCheckingHandlers(dependencies: ConflictCheckingDep
};
const ensureAllProcessed = (): Promise<boolean> => conflictQueue.waitForIdle();
const abortQueue = (): void => {
// Shutdown keeps its existing database-close order. Observe cleanup
// without making the lifecycle wait for an active resolver.
void conflictQueue.abort().catch((error) => dependencies.log(error, LOG_LEVEL_VERBOSE));
};
if (dependencies.signal.aborted) {
abortQueue();
} else {
dependencies.signal.addEventListener("abort", abortQueue, { once: true });
}
return {
queueCheckForIfOpen,
@@ -14,7 +14,7 @@ import {
type MetaEntry,
type ObsidianLiveSyncSettings,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { EVENT_CONFLICT_CANCELLED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
import { EVENT_CONFLICT_CANCELLED, EVENT_PLUGIN_UNLOADED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
import { NO_INTERACTION } from "@vrtmrz/livesync-commonlib/replication";
import type { IAPIService } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
import {
@@ -44,6 +44,7 @@ type HarnessOptions = {
function createHarness(options: HarnessOptions = {}) {
const context = createServiceContext();
const conflict = new InjectableConflictService(context);
const unloadHandlers: Array<() => Promise<boolean>> = [];
const settings = { ...DEFAULT_SETTINGS, ...options.settings };
const tryAutoMerge = vi.fn();
const databaseFileAccess = {
@@ -62,7 +63,14 @@ function createHarness(options: HarnessOptions = {}) {
const activeFile = vi.fn(() => options.activeFile);
const services = {
API: { addLog },
appLifecycle: { isSuspended: vi.fn(() => false) },
appLifecycle: {
isSuspended: vi.fn(() => false),
onUnload: {
addHandler: vi.fn((handler: () => Promise<boolean>) => {
unloadHandlers.push(handler);
}),
},
},
conflict,
context,
database: { localDatabase: { tryAutoMerge } },
@@ -88,9 +96,22 @@ function createHarness(options: HarnessOptions = {}) {
serviceModules,
storageAccess,
tryAutoMerge,
unload: async () => {
for (const handler of unloadHandlers) await handler();
},
};
}
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
}
function leaf(rev: string, data: string, mtime: number): ConflictLeaf {
return { rev, data, mtime, ctime: mtime, deleted: false };
}
@@ -919,6 +940,347 @@ describe("conflict resolution serviceFeature", () => {
expect(harness.conflict.conflictProcessQueueCount.value).toBe(0);
});
it("reports queue cancellation and logs a resolver rejection after unload", async () => {
const path = "queued-rejection-after-unload.md" as FilePathWithPrefix;
const harness = createHarness();
const started = deferred<void>();
const pendingResolve = deferred<void>();
const resolve = vi.fn(async () => {
started.resolve(undefined);
await pendingResolve.promise;
});
harness.conflict.resolve.setHandler(resolve, true);
await harness.conflict.queueCheckFor(path);
await started.promise;
const completion = harness.conflict.ensureAllProcessed().then(
(result) => ({ status: "resolved" as const, result }),
(error: unknown) => ({ status: "rejected" as const, error })
);
await harness.unload();
await harness.unload();
const failure = new Error("Resolver rejected after unload");
pendingResolve.reject(failure);
await expect(completion).resolves.toEqual({ status: "resolved", result: false });
expect(resolve).toHaveBeenCalledOnce();
expect(harness.addLog).toHaveBeenCalledWith(failure, LOG_LEVEL_VERBOSE, "");
});
it("allows an active inspection to complete normally after unload", async () => {
const path = "not-conflicted-after-unload.md" as FilePathWithPrefix;
const harness = createHarness();
const queueCheckFor = vi.spyOn(harness.conflict, "queueCheckFor");
const started = deferred<void>();
const pendingMergeCheck = deferred<{ ok: typeof NOT_CONFLICTED }>();
harness.tryAutoMerge.mockImplementation(async () => {
started.resolve(undefined);
return await pendingMergeCheck.promise;
});
const emitEvent = vi.spyOn(harness.context.events, "emitEvent");
const operation = harness.conflict.resolve(path);
const completion = operation.then(
() => ({ status: "resolved" as const }),
(error: unknown) => ({ status: "rejected" as const, error })
);
await started.promise;
expect(harness.tryAutoMerge).toHaveBeenCalledWith(path, true);
harness.context.events.emitEvent(EVENT_PLUGIN_UNLOADED);
pendingMergeCheck.resolve({ ok: NOT_CONFLICTED });
await expect(completion).resolves.toEqual({ status: "resolved" });
expect(emitEvent).toHaveBeenCalledWith(EVENT_CONFLICT_CANCELLED, path);
expect(queueCheckFor).not.toHaveBeenCalled();
expect(harness.addLog).toHaveBeenCalled();
});
it("continues an accepted auto-merge when storage completes after unload", async () => {
const path = "stored-merge-after-unload.md" as FilePathWithPrefix;
const harness = createHarness({ settings: { syncAfterMerge: false } });
const queueCheckFor = vi.spyOn(harness.conflict, "queueCheckFor");
const started = deferred<void>();
const pendingStore = deferred<boolean>();
harness.tryAutoMerge.mockResolvedValue({ result: "Merged content\n", conflictedRev: "2-conflict" });
harness.databaseFileAccess.storeContent.mockImplementation(async () => {
started.resolve(undefined);
return await pendingStore.promise;
});
const emitEvent = vi.spyOn(harness.context.events, "emitEvent");
const operation = harness.conflict.resolve(path);
const completion = operation.then(
() => ({ status: "resolved" as const }),
(error: unknown) => ({ status: "rejected" as const, error })
);
await started.promise;
expect(harness.databaseFileAccess.storeContent).toHaveBeenCalledWith(path, "Merged content\n");
await harness.unload();
pendingStore.resolve(true);
await expect(completion).resolves.toEqual({ status: "resolved" });
expect(harness.fileHandler.deleteRevisionFromDB).toHaveBeenCalledWith(path, "2-conflict");
expect(harness.fileHandler.dbToStorage).toHaveBeenCalled();
expect(queueCheckFor).toHaveBeenCalledWith(path);
expect(emitEvent).toHaveBeenCalledWith(EVENT_CONFLICT_CANCELLED, path);
expect(harness.addLog).toHaveBeenCalled();
await expect(harness.conflict.ensureAllProcessed()).resolves.toBe(false);
});
it("reflects a revision deletion which succeeds after the unload event", async () => {
const path = "deleted-after-unload.md" as FilePathWithPrefix;
const harness = createHarness();
const started = deferred<void>();
const pendingDelete = deferred<boolean>();
harness.fileHandler.deleteRevisionFromDB.mockImplementation(async () => {
started.resolve(undefined);
return await pendingDelete.promise;
});
const emitEvent = vi.spyOn(harness.context.events, "emitEvent");
const operation = harness.conflict.resolveByDeletingRevision(path, "2-conflict", "UI Selected");
const completion = operation.then(
(result) => ({ status: "resolved" as const, result }),
(error: unknown) => ({ status: "rejected" as const, error })
);
await started.promise;
expect(harness.fileHandler.deleteRevisionFromDB).toHaveBeenCalledWith(path, "2-conflict");
harness.context.events.emitEvent(EVENT_PLUGIN_UNLOADED);
pendingDelete.resolve(true);
await expect(completion).resolves.toEqual({ status: "resolved", result: AUTO_MERGED });
expect(harness.databaseFileAccess.getConflictedRevs).toHaveBeenCalledWith(path);
expect(harness.fileHandler.dbToStorage).toHaveBeenCalledWith(path, path, true);
expect(emitEvent).toHaveBeenCalledWith(EVENT_CONFLICT_CANCELLED, path);
expect(harness.addLog).toHaveBeenCalled();
});
it("lets active replication finish while rejecting its next conflict check", async () => {
const path = "replication-after-unload.md" as FilePathWithPrefix;
const harness = createHarness({ settings: { syncAfterMerge: true } });
const queueCheckFor = vi.spyOn(harness.conflict, "queueCheckFor");
const started = deferred<void>();
const pendingReplication = deferred<{ status: "completed" }>();
harness.tryAutoMerge.mockResolvedValue({ ok: AUTO_MERGED });
harness.services.replication.replicateUnattendedByEvent.mockImplementation(async () => {
started.resolve(undefined);
return await pendingReplication.promise;
});
const operation = harness.conflict.resolve(path);
const completion = operation.then(
() => ({ status: "resolved" as const }),
(error: unknown) => ({ status: "rejected" as const, error })
);
await started.promise;
expect(harness.services.replication.replicateUnattendedByEvent).toHaveBeenCalledWith({
trigger: "merge",
interaction: NO_INTERACTION,
});
await harness.unload();
pendingReplication.resolve({ status: "completed" });
await expect(completion).resolves.toEqual({ status: "resolved" });
expect(queueCheckFor).toHaveBeenCalledWith(path);
expect(harness.tryAutoMerge).toHaveBeenCalledOnce();
await expect(harness.conflict.ensureAllProcessed()).resolves.toBe(false);
expect(harness.addLog).toHaveBeenCalled();
});
it("does not start resolveByNewest when an optional conflict check returns newer after unload", async () => {
const path = "optional-newer-after-unload.md" as FilePathWithPrefix;
const harness = createHarness();
const started = deferred<void>();
const pendingOptionalCheck = deferred<"newer" | undefined>();
const optionalCheck = vi.fn(async () => {
started.resolve(undefined);
return await pendingOptionalCheck.promise;
});
const resolveByNewest = vi.fn(async () => true);
const unregisterOptionalCheck = harness.conflict.getOptionalConflictCheckMethod.addHandler(optionalCheck);
harness.conflict.resolveByNewest.setHandler(resolveByNewest, true);
const operation = harness.conflict.queueCheckFor(path);
const completion = operation.then(
() => ({ status: "resolved" as const }),
(error: unknown) => ({ status: "rejected" as const, error })
);
try {
await started.promise;
expect(optionalCheck).toHaveBeenCalledWith(path);
await harness.unload();
harness.addLog.mockImplementation(() => {
throw new Error("Handler addLog not assigned");
});
pendingOptionalCheck.resolve("newer");
await expect(completion).resolves.toEqual({ status: "resolved" });
expect(resolveByNewest).not.toHaveBeenCalled();
expect(harness.addLog).not.toHaveBeenCalled();
} finally {
unregisterOptionalCheck();
}
});
it.each(["lifecycle", "event"] as const)(
"stops admission and preserves ordinary database failures after %s disposal",
async (trigger) => {
const path = "operation-after-unload.md" as FilePathWithPrefix;
const harness = createHarness();
const optionalCheck = vi.fn(async () => "newer" as const);
const unregisterOptionalCheck = harness.conflict.getOptionalConflictCheckMethod.addHandler(optionalCheck);
const publicOperations = [
vi.spyOn(harness.conflict, "queueCheckForIfOpen"),
vi.spyOn(harness.conflict, "queueCheckFor"),
vi.spyOn(harness.conflict, "resolve"),
vi.spyOn(harness.conflict, "resolveByDeletingRevision"),
vi.spyOn(harness.conflict, "resolveByNewest"),
vi.spyOn(harness.conflict, "resolveAllConflictedFilesByNewerOnes"),
];
const emitEvent = vi.spyOn(harness.context.events, "emitEvent");
if (trigger === "lifecycle") {
await harness.unload();
} else {
harness.context.events.emitEvent(EVENT_PLUGIN_UNLOADED);
}
const failure = new Error("database closed");
harness.tryAutoMerge.mockRejectedValue(failure);
harness.databaseFileAccess.fetchEntryMeta.mockResolvedValue(false);
harness.fileHandler.deleteRevisionFromDB.mockResolvedValue(false);
try {
const results = await Promise.allSettled([
harness.conflict.queueCheckForIfOpen(path),
harness.conflict.queueCheckFor(path),
harness.conflict.resolve(path),
harness.conflict.resolveByDeletingRevision(path, "2-conflict", "UI Selected"),
harness.conflict.resolveByNewest(path),
harness.conflict.resolveAllConflictedFilesByNewerOnes(),
]);
expect(results).toEqual([
{ status: "fulfilled", value: undefined },
{ status: "fulfilled", value: undefined },
{ status: "rejected", reason: failure },
{ status: "fulfilled", value: MISSING_OR_ERROR },
{ status: "fulfilled", value: false },
{ status: "fulfilled", value: undefined },
]);
await expect(harness.conflict.ensureAllProcessed()).resolves.toBe(false);
for (const operation of publicOperations) expect(operation).toHaveBeenCalledOnce();
expect(optionalCheck).not.toHaveBeenCalled();
expect(harness.tryAutoMerge).toHaveBeenCalledOnce();
expect(harness.databaseFileAccess.fetchEntryMeta).toHaveBeenCalledOnce();
expect(harness.databaseFileAccess.getConflictedRevs).not.toHaveBeenCalled();
expect(harness.databaseFileAccess.storeContent).not.toHaveBeenCalled();
expect(harness.fileHandler.deleteRevisionFromDB).toHaveBeenCalledOnce();
expect(harness.fileHandler.dbToStorage).not.toHaveBeenCalled();
expect(harness.storageAccess.getFileNames).toHaveBeenCalledOnce();
expect(harness.services.replication.replicateUnattendedByEvent).not.toHaveBeenCalled();
expect(emitEvent).not.toHaveBeenCalledWith(EVENT_CONFLICT_CANCELLED, path);
expect(harness.addLog).toHaveBeenCalled();
} finally {
unregisterOptionalCheck();
}
}
);
it("cancels unstarted checks after active checks settle during unload", async () => {
const paths = Array.from({ length: 11 }, (_, index) => `unload-concurrent-${index}.md` as FilePathWithPrefix);
const harness = createHarness();
const tenStarted = deferred<void>();
const releaseResolvers = deferred<void>();
const resolve = vi.fn(async (_path: FilePathWithPrefix) => {
if (resolve.mock.calls.length === 10) tenStarted.resolve(undefined);
await releaseResolvers.promise;
});
harness.conflict.resolve.setHandler(resolve, true);
await Promise.all(paths.map(async (path) => await harness.conflict.queueCheckFor(path)));
await tenStarted.promise;
expect(harness.conflict.conflictProcessQueueCount.value).toBe(11);
const completion = harness.conflict.ensureAllProcessed();
harness.context.events.emitEvent(EVENT_PLUGIN_UNLOADED);
releaseResolvers.resolve(undefined);
await expect(completion).resolves.toBe(false);
expect(resolve).toHaveBeenCalledTimes(10);
expect(harness.conflict.conflictProcessQueueCount.value).toBe(1);
});
it("reports an unavailable database when an active bulk scan completes after unload", async () => {
const path = "bulk-resolution-after-unload.md" as FilePathWithPrefix;
const harness = createHarness();
const namesStarted = deferred<void>();
const pendingNames = deferred<FilePathWithPrefix[]>();
harness.storageAccess.getFileNames.mockImplementation(async () => {
namesStarted.resolve(undefined);
return await pendingNames.promise;
});
const operation = harness.conflict.resolveAllConflictedFilesByNewerOnes();
await namesStarted.promise;
expect(harness.addLog).toHaveBeenCalled();
harness.addLog.mockClear();
await harness.unload();
harness.databaseFileAccess.fetchEntryMeta.mockResolvedValue(false);
pendingNames.resolve([path]);
await expect(operation).resolves.toBeUndefined();
expect(harness.databaseFileAccess.fetchEntryMeta).toHaveBeenCalledWith(path, undefined, true);
expect(harness.databaseFileAccess.getConflictedRevs).not.toHaveBeenCalled();
expect(harness.fileHandler.deleteRevisionFromDB).not.toHaveBeenCalled();
expect(harness.fileHandler.dbToStorage).not.toHaveBeenCalled();
expect(
harness.addLog.mock.calls.some(([message]) => String(message).includes("Could not get current revision"))
).toBe(true);
});
it("keeps a replacement queue independent of repeated shutdown of the previous queue", async () => {
const first = createHarness();
const second = createHarness();
first.tryAutoMerge.mockResolvedValue({ ok: NOT_CONFLICTED });
second.tryAutoMerge.mockResolvedValue({ ok: NOT_CONFLICTED });
first.context.events.emitEvent(EVENT_PLUGIN_UNLOADED);
await first.unload();
await first.unload();
await first.conflict.queueCheckFor("retired.md" as FilePathWithPrefix);
await second.conflict.queueCheckFor("active.md" as FilePathWithPrefix);
await expect(first.conflict.ensureAllProcessed()).resolves.toBe(false);
await expect(second.conflict.ensureAllProcessed()).resolves.toBe(true);
expect(first.tryAutoMerge).not.toHaveBeenCalled();
expect(second.tryAutoMerge).toHaveBeenCalledExactlyOnceWith("active.md", true);
});
it("reports a storage failure after shutdown without treating the queue as completed", async () => {
const harness = createHarness();
const started = deferred<void>();
const pendingStore = deferred<boolean>();
const failure = new Error("database is closed");
harness.tryAutoMerge.mockResolvedValue({ result: "Merged content", conflictedRev: "2-conflict" });
harness.databaseFileAccess.storeContent.mockImplementation(() => {
started.resolve(undefined);
return pendingStore.promise;
});
await harness.conflict.queueCheckFor("closed-database.md" as FilePathWithPrefix);
await started.promise;
const completion = harness.conflict.ensureAllProcessed();
await harness.unload();
pendingStore.reject(failure);
await expect(completion).resolves.toBe(false);
expect(harness.fileHandler.deleteRevisionFromDB).not.toHaveBeenCalled();
expect(harness.fileHandler.dbToStorage).not.toHaveBeenCalled();
expect(harness.addLog).toHaveBeenCalledWith(failure, LOG_LEVEL_VERBOSE, "");
});
it("drains requeued automatic checks when every resolver slot is occupied", async () => {
const harness = createHarness({ settings: { syncAfterMerge: false } });
const paths = Array.from({ length: 10 }, (_, index) => `requeue-${index}.md` as FilePathWithPrefix);
@@ -0,0 +1,89 @@
import { describe, expect, it, vi } from "vitest";
import PouchDB from "pouchdb-core";
import MemoryAdapter from "pouchdb-adapter-memory";
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
import { InjectableConflictService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableConflictService";
import { LOG_LEVEL_VERBOSE, type FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { createConflictCheckingHandlers } from "./checker";
PouchDB.plugin(MemoryAdapter);
type Probe = { completed: number };
function deferred() {
let resolve!: () => void;
const promise = new Promise<void>((finish) => {
resolve = finish;
});
return { promise, resolve };
}
describe("conflict-check shutdown with a real PouchDB handle", () => {
it.each([false, true])(
"reports cancellation and preserves the database outcome when closed before completion: %s",
async (closeDuringShutdown) => {
const name = `conflict-shutdown-${crypto.randomUUID()}`;
const db = new PouchDB<Probe>(name, { adapter: "memory" });
const started = deferred();
const release = deferred();
const conflict = new InjectableConflictService(createServiceContext());
const lifetime = new AbortController();
const log = vi.fn();
const resolver = vi.fn(async () => {
started.resolve();
await release.promise;
const probe = await db.get("_local/shutdown-probe");
await db.put({ ...probe, completed: probe.completed + 1 });
});
conflict.resolve.setHandler(resolver);
const checking = createConflictCheckingHandlers({
conflict,
signal: lifetime.signal,
conflictProcessQueueCount: conflict.conflictProcessQueueCount,
cancelActiveResolution: vi.fn(),
currentSettings: () => ({ checkConflictOnlyOnOpen: false }),
vault: { getActiveFilePath: () => undefined },
log,
});
conflict.queueCheckFor.setHandler(checking.queueCheckFor);
let closed = false;
let reopened: PouchDB.Database<Probe> | undefined;
let completion: Promise<boolean> | undefined;
try {
await db.put({ _id: "_local/shutdown-probe", completed: 0 });
await checking.queueCheckFor("active.md" as FilePathWithPrefix);
await started.promise;
completion = checking.ensureAllProcessed();
lifetime.abort();
lifetime.abort();
await checking.queueCheckFor("late.md" as FilePathWithPrefix);
if (closeDuringShutdown) {
await db.close();
closed = true;
}
release.resolve();
await expect(completion).resolves.toBe(false);
expect(resolver).toHaveBeenCalledOnce();
if (closeDuringShutdown) {
expect(log).toHaveBeenCalledWith(
expect.objectContaining({ message: expect.stringMatching(/closed/i) }),
LOG_LEVEL_VERBOSE
);
} else {
expect(log).not.toHaveBeenCalled();
await db.close();
closed = true;
}
reopened = new PouchDB<Probe>(name, { adapter: "memory" });
const persisted = await reopened.get("_local/shutdown-probe");
expect(persisted.completed).toBe(closeDuringShutdown ? 0 : 1);
} finally {
release.resolve();
await completion?.catch(() => undefined);
if (!closed) await db.close();
await (reopened ?? new PouchDB<Probe>(name, { adapter: "memory" })).destroy();
}
}
);
});
@@ -6,6 +6,7 @@ import type { LogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib
import type { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub";
import { createConflictCheckingHandlers } from "./checker";
import { createConflictResolutionOperations } from "./operations";
import { EVENT_PLUGIN_UNLOADED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
export type ConflictResolutionHost = NecessaryServices<
"appLifecycle" | "conflict" | "database" | "replication" | "setting" | "vault",
@@ -18,6 +19,7 @@ export type ConflictResolutionHost = NecessaryServices<
/** Compose the host-neutral conflict handlers with the host's borrowed logger. */
export const useConflictResolutionFeature = createServiceFeature((host: ConflictResolutionHost): void => {
const { services, serviceModules, log } = host;
const lifetime = new AbortController();
const operations = createConflictResolutionOperations({
events: services.context.events,
databaseFileAccess: serviceModules.databaseFileAccess,
@@ -38,6 +40,7 @@ export const useConflictResolutionFeature = createServiceFeature((host: Conflict
currentSettings: () => services.setting.currentSettings(),
vault: services.vault,
log,
signal: lifetime.signal,
});
services.conflict.queueCheckForIfOpen.setHandler(checking.queueCheckForIfOpen);
@@ -47,6 +50,13 @@ export const useConflictResolutionFeature = createServiceFeature((host: Conflict
services.conflict.resolve.setHandler(operations.resolve);
services.conflict.resolveByNewest.setHandler(operations.resolveByNewest);
services.conflict.resolveAllConflictedFilesByNewerOnes.setHandler(operations.resolveAllConflictedFilesByNewerOnes);
const stop = (): void => lifetime.abort();
services.context.events.onceEvent(EVENT_PLUGIN_UNLOADED, stop, { signal: lifetime.signal });
services.appLifecycle.onUnload.addHandler(() => {
stop();
return Promise.resolve(true);
});
});
export { createConflictCheckingHandlers } from "./checker";
@@ -27,9 +27,10 @@ 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 { parseDocumentPath, splitDocumentPath } from "@vrtmrz/livesync-commonlib/storage";
import { isLockAcquired, withLock } 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";
import { isPlainText } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
export type ConflictResolutionSettings = Pick<
ObsidianLiveSyncSettings,
@@ -67,6 +68,7 @@ export interface ConflictResolutionOperations {
readonly resolveAllConflictedFilesByNewerOnes: () => Promise<void>;
}
/** Create conflict-resolution operations which retain their normal completion and failure paths. */
export function createConflictResolutionOperations(
dependencies: ConflictResolutionOperationsDependencies
): ConflictResolutionOperations {
@@ -108,7 +110,9 @@ export function createConflictResolutionOperations(
return AUTO_MERGED;
}
// If no conflicts were found, write the resolved content to the storage.
if (!(await dependencies.fileHandler.dbToStorage(path, stripAllPrefixes(path), true))) {
const documentPath = parseDocumentPath(path);
const { path: filePath } = splitDocumentPath(documentPath);
if (!(await dependencies.fileHandler.dbToStorage(documentPath, filePath, true))) {
dependencies.log(`Could not write the resolved content to the storage: ${path}`, LOG_LEVEL_NOTICE);
return MISSING_OR_ERROR;
}
@@ -2,7 +2,7 @@ import type { FilePath, FilePathWithPrefix, UXFileInfo } from "@vrtmrz/livesync-
import { readAsBlob } 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 { stripAllPrefixes } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
import { parseDocumentPath, splitDocumentPath } from "@vrtmrz/livesync-commonlib/storage";
export type DocumentHistoryRestorationCore = {
databaseFileAccess: Pick<
@@ -54,8 +54,8 @@ export async function restoreDocumentHistoryRevision(
}
const body = readAsBlob(source);
const storagePath = stripAllPrefixes(current.path);
if (storagePath !== current.path || !isPathValid(storagePath)) {
const { prefix, path: storagePath } = splitDocumentPath(parseDocumentPath(current.path));
if (prefix !== "" || !isPathValid(storagePath)) {
return { status: "unsupported-path" };
}
const file: UXFileInfo = {
@@ -17,8 +17,10 @@ export function useInteractiveConflictResolutionFeature(
createDialogue: ConflictResolveDialogueFactory
): void {
const services = host.services;
const lifetime = new AbortController();
const operations = createInteractiveConflictResolutionOperations({
events: services.context.events,
signal: lifetime.signal,
databaseFileAccess: host.serviceModules.databaseFileAccess,
localDatabase: () => services.database.localDatabase,
confirm: services.UI.confirm,
@@ -61,24 +63,18 @@ export function useInteractiveConflictResolutionFeature(
});
services.appLifecycle.getUnresolvedMessages.addHandler(operations.getActiveConflictMessages);
services.conflict.resolveByUserInteraction.addHandler(operations.resolveByUserInteraction);
const eventSubscriptions = new AbortController();
const dispose = () => {
// Stop the refresh listener before cancellation so that unloading does
// not start a database read which can race with database disposal.
eventSubscriptions.abort();
operations.dispose();
};
services.context.events.onEvent(
EVENT_CONFLICT_CANCELLED,
(filename) => {
operations.invalidateWaitingResolution(filename);
fireAndForget(() => operations.refreshConflictState(filename));
},
{ signal: eventSubscriptions.signal }
{ signal: lifetime.signal }
);
services.context.events.onceEvent(EVENT_PLUGIN_UNLOADED, dispose, { signal: eventSubscriptions.signal });
const stop = (): void => lifetime.abort();
services.context.events.onceEvent(EVENT_PLUGIN_UNLOADED, stop, { signal: lifetime.signal });
services.appLifecycle.onUnload.addHandler(() => {
dispose();
stop();
return Promise.resolve(true);
});
}
@@ -1,5 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
import { bindableFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/HandlerUtils";
import { createInstanceLogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
import {
AUTO_MERGED,
CANCELLED,
@@ -30,8 +32,12 @@ async function* documents(items: unknown[]) {
}
}
function createOperations(conflictedRevisions: string[] = ["2-right"]) {
function createOperations(
conflictedRevisions: string[] = ["2-right"],
overrides: Partial<InteractiveConflictResolutionOperationsDependencies> = {}
) {
const context = createServiceContext();
const lifetime = new AbortController();
let dialogueResult: unknown = POSTPONED;
const constructed = { value: 0 };
const getDBEntry = vi.fn(async (): Promise<false | { _rev: string; _conflicts?: string[] }> => false);
@@ -50,6 +56,7 @@ function createOperations(conflictedRevisions: string[] = ["2-right"]) {
const log = vi.fn();
const operations = createInteractiveConflictResolutionOperations({
events: context.events,
signal: overrides.signal ?? lifetime.signal,
databaseFileAccess: {
getConflictedRevs,
storeContent,
@@ -75,8 +82,10 @@ function createOperations(conflictedRevisions: string[] = ["2-right"]) {
};
}),
log,
...overrides,
} as unknown as InteractiveConflictResolutionOperationsDependencies);
return {
abort: () => lifetime.abort(),
askSelectString,
askInPopup,
constructed,
@@ -148,11 +157,13 @@ function createControlledDialogueFactory(
function createDialogueConcurrencyHarness() {
const context = createServiceContext();
const lifetime = new AbortController();
const emitEvent = vi.spyOn(context.events, "emitEvent");
const dialogues: ControlledDialogue[] = [];
const createDialogue = createControlledDialogueFactory(context, dialogues);
const operations = createInteractiveConflictResolutionOperations({
events: context.events,
signal: lifetime.signal,
databaseFileAccess: {
getConflictedRevs: vi.fn(async () => ["2-right"]),
storeContent: vi.fn(async () => true),
@@ -177,10 +188,447 @@ function createDialogueConcurrencyHarness() {
createDialogue: createDialogue as ConflictResolveDialogueFactory,
log: vi.fn(),
} as unknown as InteractiveConflictResolutionOperationsDependencies);
return { context, createDialogue, dialogues, emitEvent, operations };
return { abort: () => lifetime.abort(), context, createDialogue, dialogues, emitEvent, operations };
}
describe("interactive conflict resolution operations", () => {
it("returns cancellation to the caller waiting for a requested conflict check", async () => {
const harness = createOperations();
harness.ensureAllProcessed.mockResolvedValue(false);
await expect(harness.operations.requestConflictResolution(path)).resolves.toBe(false);
expect(harness.queueCheckFor).toHaveBeenCalledWith(path);
});
it.each([
{ label: "cancelled", result: CANCELLED },
{ label: "postponed", result: POSTPONED },
{ label: "selected", result: "2-right" },
] as const)("ignores a $label dialogue result which settles after disposal", async ({ result }) => {
let finish!: (result: MergeDialogResult) => void;
const response = new Promise<MergeDialogResult>((resolve) => {
finish = resolve;
});
const sink = vi.fn();
const addLog = bindableFunction<(message: unknown) => void>("addLog");
const unregisterLog = addLog.setHandler(sink);
const createDialogue = vi.fn(() => ({ open: vi.fn(), waitForResult: () => response }));
const fixture = createOperations(undefined, {
createDialogue,
log: createInstanceLogFunction("Conflict shutdown", { addLog } as never),
});
const active = fixture.operations.resolveByUserInteraction(path, conflict);
await vi.waitFor(() => expect(createDialogue).toHaveBeenCalledOnce());
const waiting = fixture.operations.resolveByUserInteraction("waiting.md" as FilePathWithPrefix, conflict);
const settled = Promise.allSettled([active, waiting]);
const logsBeforeDisposal = sink.mock.calls.length;
fixture.abort();
unregisterLog();
finish(result);
expect(await settled).toEqual([
{ status: "fulfilled", value: false },
{ status: "fulfilled", value: false },
]);
expect(createDialogue).toHaveBeenCalledOnce();
expect(sink).toHaveBeenCalledTimes(logsBeforeDisposal);
expect(fixture.getDBEntry).not.toHaveBeenCalled();
expect(fixture.storeContent).not.toHaveBeenCalled();
expect(fixture.resolveByDeletingRevision).not.toHaveBeenCalled();
expect(fixture.replicateUnattendedByEvent).not.toHaveBeenCalled();
expect(fixture.queueCheckFor).not.toHaveBeenCalled();
});
it("logs a failed database read and returns normally after disposal", async () => {
let finishRead!: (value: false | { _rev: string; _conflicts?: string[] }) => void;
const pendingRead = new Promise<false | { _rev: string; _conflicts?: string[] }>((resolve) => {
finishRead = resolve;
});
const sink = vi.fn();
const addLog = bindableFunction<(message: unknown) => void>("addLog");
addLog.setHandler(sink);
const fixture = createOperations(undefined, {
createDialogue: vi.fn(() => ({
open: vi.fn(),
waitForResult: vi.fn(async () => "2-right"),
})),
log: createInstanceLogFunction("Conflict shutdown", { addLog } as never),
});
fixture.getDBEntry.mockReturnValue(pendingRead);
const resolving = fixture.operations.resolveByUserInteraction(path, conflict);
await vi.waitFor(() => expect(fixture.getDBEntry).toHaveBeenCalledOnce());
const logsBeforeDisposal = sink.mock.calls.length;
fixture.abort();
finishRead(false);
await expect(resolving).resolves.toBe(false);
expect(sink).toHaveBeenCalledTimes(logsBeforeDisposal + 1);
expect(sink).toHaveBeenCalledWith(
expect.stringContaining(`Merge: Could not read ${path} from the local database`),
expect.anything(),
expect.anything()
);
expect(fixture.storeContent).not.toHaveBeenCalled();
expect(fixture.resolveByDeletingRevision).not.toHaveBeenCalled();
expect(fixture.replicateUnattendedByEvent).not.toHaveBeenCalled();
expect(fixture.queueCheckFor).not.toHaveBeenCalled();
});
it("continues a selected conflict after the database read succeeds following disposal", async () => {
let finishRead!: (value: false | { _rev: string; _conflicts?: string[] }) => void;
const pendingRead = new Promise<false | { _rev: string; _conflicts?: string[] }>((resolve) => {
finishRead = resolve;
});
const fixture = createOperations(undefined, {
createDialogue: vi.fn(() => ({
open: vi.fn(),
waitForResult: vi.fn(async () => "2-right"),
})),
});
fixture.getDBEntry.mockReturnValue(pendingRead);
const resolving = fixture.operations.resolveByUserInteraction(path, conflict);
await vi.waitFor(() => expect(fixture.getDBEntry).toHaveBeenCalledOnce());
fixture.abort();
finishRead({ _rev: "2-left", _conflicts: ["2-right"] });
await expect(resolving).resolves.toBe(false);
expect(fixture.resolveByDeletingRevision).toHaveBeenCalledWith(path, "2-right", "UI Selected");
expect(fixture.replicateUnattendedByEvent).not.toHaveBeenCalled();
expect(fixture.queueCheckFor).toHaveBeenCalledWith(path);
});
it("logs a failed content store after disposal", async () => {
let finishStore!: (stored: boolean) => void;
const pendingStore = new Promise<boolean>((resolve) => {
finishStore = resolve;
});
const sink = vi.fn();
const addLog = bindableFunction<(message: unknown) => void>("addLog");
addLog.setHandler(sink);
const fixture = createOperations(undefined, {
log: createInstanceLogFunction("Conflict shutdown", { addLog } as never),
});
fixture.dialogueResult.value = LEAVE_TO_SUBSEQUENT;
fixture.getDBEntry.mockResolvedValue({ _rev: "2-left", _conflicts: ["2-right"] });
fixture.storeContent.mockReturnValue(pendingStore);
const resolving = fixture.operations.resolveByUserInteraction(path, conflict);
await vi.waitFor(() => expect(fixture.storeContent).toHaveBeenCalledOnce());
const logsBeforeDisposal = sink.mock.calls.length;
fixture.abort();
finishStore(false);
await expect(resolving).resolves.toBe(false);
expect(sink).toHaveBeenCalledTimes(logsBeforeDisposal + 1);
expect(sink).toHaveBeenCalledWith(
`Concatenated content cannot be stored:${path}`,
expect.anything(),
expect.anything()
);
expect(fixture.resolveByDeletingRevision).not.toHaveBeenCalled();
expect(fixture.replicateUnattendedByEvent).not.toHaveBeenCalled();
expect(fixture.queueCheckFor).not.toHaveBeenCalled();
});
it("continues a selected conflict after content storage succeeds following disposal", async () => {
let finishStore!: (stored: boolean) => void;
const pendingStore = new Promise<boolean>((resolve) => {
finishStore = resolve;
});
const sink = vi.fn();
const addLog = bindableFunction<(message: unknown) => void>("addLog");
addLog.setHandler(sink);
const fixture = createOperations(undefined, {
log: createInstanceLogFunction("Conflict shutdown", { addLog } as never),
});
fixture.dialogueResult.value = LEAVE_TO_SUBSEQUENT;
fixture.getDBEntry.mockResolvedValue({ _rev: "2-left", _conflicts: ["2-right"] });
fixture.storeContent.mockReturnValue(pendingStore);
fixture.currentSettings.mockReturnValue({ syncAfterMerge: true });
const resolving = fixture.operations.resolveByUserInteraction(path, conflict);
await vi.waitFor(() => expect(fixture.storeContent).toHaveBeenCalledOnce());
const logsBeforeDisposal = sink.mock.calls.length;
fixture.abort();
finishStore(true);
await expect(resolving).resolves.toBe(false);
expect(sink).toHaveBeenCalledTimes(logsBeforeDisposal);
expect(fixture.resolveByDeletingRevision).toHaveBeenCalledWith(path, "2-right", "UI Concatenated");
expect(fixture.replicateUnattendedByEvent).toHaveBeenCalledWith({
trigger: "merge",
interaction: expect.anything(),
});
expect(fixture.queueCheckFor).toHaveBeenCalledWith(path);
});
it("logs a failed revision deletion after disposal", async () => {
let finishDeletion!: (result: typeof AUTO_MERGED | typeof MISSING_OR_ERROR) => void;
const pendingDeletion = new Promise<typeof AUTO_MERGED | typeof MISSING_OR_ERROR>((resolve) => {
finishDeletion = resolve;
});
const sink = vi.fn();
const addLog = bindableFunction<(message: unknown) => void>("addLog");
addLog.setHandler(sink);
const fixture = createOperations(undefined, {
log: createInstanceLogFunction("Conflict shutdown", { addLog } as never),
});
fixture.dialogueResult.value = "2-right";
fixture.getDBEntry.mockResolvedValue({ _rev: "2-left", _conflicts: ["2-right"] });
fixture.resolveByDeletingRevision.mockReturnValue(pendingDeletion);
const resolving = fixture.operations.resolveByUserInteraction(path, conflict);
await vi.waitFor(() => expect(fixture.resolveByDeletingRevision).toHaveBeenCalledOnce());
const logsBeforeDisposal = sink.mock.calls.length;
fixture.abort();
finishDeletion(MISSING_OR_ERROR);
await expect(resolving).resolves.toBe(false);
expect(sink).toHaveBeenCalledTimes(logsBeforeDisposal + 1);
expect(sink).toHaveBeenCalledWith(
expect.stringContaining(`Merge: Something went wrong: ${path}`),
expect.anything(),
expect.anything()
);
expect(fixture.replicateUnattendedByEvent).not.toHaveBeenCalled();
expect(fixture.queueCheckFor).not.toHaveBeenCalled();
});
it("replicates and requeues after revision deletion succeeds following disposal", async () => {
let finishDeletion!: (result: typeof AUTO_MERGED | typeof MISSING_OR_ERROR) => void;
const pendingDeletion = new Promise<typeof AUTO_MERGED | typeof MISSING_OR_ERROR>((resolve) => {
finishDeletion = resolve;
});
const sink = vi.fn();
const addLog = bindableFunction<(message: unknown) => void>("addLog");
addLog.setHandler(sink);
const fixture = createOperations(undefined, {
log: createInstanceLogFunction("Conflict shutdown", { addLog } as never),
});
fixture.dialogueResult.value = "2-right";
fixture.getDBEntry.mockResolvedValue({ _rev: "2-left", _conflicts: ["2-right"] });
fixture.resolveByDeletingRevision.mockReturnValue(pendingDeletion);
fixture.currentSettings.mockReturnValue({ syncAfterMerge: true });
const resolving = fixture.operations.resolveByUserInteraction(path, conflict);
await vi.waitFor(() => expect(fixture.resolveByDeletingRevision).toHaveBeenCalledOnce());
const logsBeforeDisposal = sink.mock.calls.length;
fixture.abort();
finishDeletion(AUTO_MERGED);
await expect(resolving).resolves.toBe(false);
expect(sink).toHaveBeenCalledTimes(logsBeforeDisposal);
expect(fixture.replicateUnattendedByEvent).toHaveBeenCalledWith({
trigger: "merge",
interaction: expect.anything(),
});
expect(fixture.queueCheckFor).toHaveBeenCalledWith(path);
});
it("logs a failed conflict inspection but does not refresh UI after disposal", async () => {
let rejectInspection!: (reason: unknown) => void;
const pendingInspection = new Promise<string[]>((_resolve, reject) => {
rejectInspection = reject;
});
const sink = vi.fn();
const addLog = bindableFunction<(message: unknown) => void>("addLog");
addLog.setHandler(sink);
const fixture = createOperations(undefined, {
log: createInstanceLogFunction("Conflict shutdown", { addLog } as never),
});
fixture.getConflictedRevs.mockReturnValue(pendingInspection);
const emitEvent = vi.spyOn(fixture.context.events, "emitEvent");
const refreshing = fixture.operations.refreshConflictState(path);
await vi.waitFor(() => expect(fixture.getConflictedRevs).toHaveBeenCalledOnce());
const logsBeforeDisposal = sink.mock.calls.length;
fixture.abort();
rejectInspection(new Error("database unavailable"));
await expect(refreshing).resolves.toBeUndefined();
expect(sink).toHaveBeenCalledTimes(logsBeforeDisposal + 2);
expect(sink).toHaveBeenCalledWith(
expect.stringContaining(`Could not inspect the conflict state of ${path}`),
expect.anything(),
expect.anything()
);
expect(sink).toHaveBeenCalledWith(
expect.objectContaining({ message: "database unavailable" }),
expect.anything(),
expect.anything()
);
expect(emitEvent).not.toHaveBeenCalledWith(EVENT_ON_UNRESOLVED_ERROR);
});
it("does not emit a successful conflict refresh after disposal", async () => {
let finishInspection!: (revisions: string[]) => void;
const pendingInspection = new Promise<string[]>((resolve) => {
finishInspection = resolve;
});
const sink = vi.fn();
const addLog = bindableFunction<(message: unknown) => void>("addLog");
addLog.setHandler(sink);
const fixture = createOperations(undefined, {
log: createInstanceLogFunction("Conflict shutdown", { addLog } as never),
});
fixture.getConflictedRevs.mockReturnValue(pendingInspection);
const emitEvent = vi.spyOn(fixture.context.events, "emitEvent");
const refreshing = fixture.operations.refreshConflictState(path);
await vi.waitFor(() => expect(fixture.getConflictedRevs).toHaveBeenCalledOnce());
const logsBeforeDisposal = sink.mock.calls.length;
fixture.abort();
finishInspection(["2-right"]);
await expect(refreshing).resolves.toBeUndefined();
expect(sink).toHaveBeenCalledTimes(logsBeforeDisposal);
expect(emitEvent).not.toHaveBeenCalledWith(EVENT_ON_UNRESOLVED_ERROR);
});
it("returns no active conflict messages when inspection settles after disposal", async () => {
let finishInspection!: (revisions: string[]) => void;
const pendingInspection = new Promise<string[]>((resolve) => {
finishInspection = resolve;
});
const fixture = createOperations(undefined, {
log: vi.fn(),
});
fixture.getConflictedRevs.mockReturnValue(pendingInspection);
const messages = fixture.operations.getActiveConflictMessages();
await vi.waitFor(() => expect(fixture.getConflictedRevs).toHaveBeenCalledOnce());
fixture.abort();
finishInspection(["2-right"]);
await expect(messages).resolves.toEqual([]);
});
it("does not inspect or publish conflict state when called after disposal", async () => {
const fixture = createOperations();
const emit = vi.spyOn(fixture.context.events, "emitEvent");
fixture.abort();
emit.mockClear();
await expect(fixture.operations.getActiveConflictMessages()).resolves.toEqual([]);
await fixture.operations.refreshConflictState(path);
expect(fixture.getConflictedRevs).not.toHaveBeenCalled();
expect(fixture.getActiveFilePath).not.toHaveBeenCalled();
expect(fixture.log).not.toHaveBeenCalled();
expect(emit).not.toHaveBeenCalled();
});
it("does not requeue a changed comparison when its conflict refresh finishes after disposal", async () => {
let finishInspection!: (revisions: string[]) => void;
const pendingInspection = new Promise<string[]>((resolve) => {
finishInspection = resolve;
});
const sink = vi.fn();
const addLog = bindableFunction<(message: unknown) => void>("addLog");
addLog.setHandler(sink);
const fixture = createOperations(undefined, {
log: createInstanceLogFunction("Conflict shutdown", { addLog } as never),
});
fixture.dialogueResult.value = "2-right";
fixture.getDBEntry.mockResolvedValue({ _rev: "3-new-left", _conflicts: ["2-right"] });
fixture.getConflictedRevs.mockReturnValue(pendingInspection);
const emit = vi.spyOn(fixture.context.events, "emitEvent");
const resolving = fixture.operations.resolveByUserInteraction(path, conflict);
await vi.waitFor(() => expect(fixture.getConflictedRevs).toHaveBeenCalledOnce());
const logsBeforeDisposal = sink.mock.calls.length;
fixture.abort();
emit.mockClear();
finishInspection(["2-right"]);
await expect(resolving).resolves.toBe(false);
expect(sink).toHaveBeenCalledTimes(logsBeforeDisposal);
expect(emit).not.toHaveBeenCalled();
expect(fixture.storeContent).not.toHaveBeenCalled();
expect(fixture.resolveByDeletingRevision).not.toHaveBeenCalled();
expect(fixture.queueCheckFor).not.toHaveBeenCalled();
});
it.each([AUTO_MERGED, MISSING_OR_ERROR] as const)(
"continues concatenated resolution according to deletion result after disposal (%s)",
async (result) => {
let finishDeletion!: (value: typeof AUTO_MERGED | typeof MISSING_OR_ERROR) => void;
const pendingDeletion = new Promise<typeof AUTO_MERGED | typeof MISSING_OR_ERROR>((resolve) => {
finishDeletion = resolve;
});
const sink = vi.fn();
const addLog = bindableFunction<(message: unknown) => void>("addLog");
addLog.setHandler(sink);
const fixture = createOperations(undefined, {
log: createInstanceLogFunction("Conflict shutdown", { addLog } as never),
});
fixture.dialogueResult.value = LEAVE_TO_SUBSEQUENT;
fixture.getDBEntry.mockResolvedValue({ _rev: "2-left", _conflicts: ["2-right"] });
fixture.resolveByDeletingRevision.mockReturnValue(pendingDeletion);
fixture.currentSettings.mockReturnValue({ syncAfterMerge: true });
const resolving = fixture.operations.resolveByUserInteraction(path, conflict);
await vi.waitFor(() => expect(fixture.resolveByDeletingRevision).toHaveBeenCalledOnce());
expect(fixture.storeContent).toHaveBeenCalledOnce();
const logsBeforeDisposal = sink.mock.calls.length;
fixture.abort();
finishDeletion(result);
await expect(resolving).resolves.toBe(false);
expect(fixture.resolveByDeletingRevision).toHaveBeenCalledTimes(1);
if (result === MISSING_OR_ERROR) {
expect(sink).toHaveBeenCalledTimes(logsBeforeDisposal + 1);
expect(sink).toHaveBeenCalledWith(
expect.stringContaining("Concatenated saved, but cannot delete conflicted revisions:"),
expect.anything(),
expect.anything()
);
expect(fixture.replicateUnattendedByEvent).not.toHaveBeenCalled();
expect(fixture.queueCheckFor).not.toHaveBeenCalled();
} else {
expect(sink).toHaveBeenCalledTimes(logsBeforeDisposal);
expect(fixture.replicateUnattendedByEvent).toHaveBeenCalledWith({
trigger: "merge",
interaction: expect.anything(),
});
expect(fixture.queueCheckFor).toHaveBeenCalledWith(path);
}
}
);
it("requeues after in-flight replication completes following disposal", async () => {
let finishReplication!: (result: { status: "completed" }) => void;
const pendingReplication = new Promise<{ status: "completed" }>((resolve) => {
finishReplication = resolve;
});
const fixture = createOperations();
fixture.dialogueResult.value = "2-right";
fixture.getDBEntry.mockResolvedValue({ _rev: "2-left", _conflicts: ["2-right"] });
fixture.currentSettings.mockReturnValue({ syncAfterMerge: true });
fixture.replicateUnattendedByEvent.mockReturnValue(pendingReplication);
const resolving = fixture.operations.resolveByUserInteraction(path, conflict);
await vi.waitFor(() => expect(fixture.replicateUnattendedByEvent).toHaveBeenCalledOnce());
fixture.abort();
finishReplication({ status: "completed" });
await expect(resolving).resolves.toBe(false);
expect(fixture.resolveByDeletingRevision).toHaveBeenCalledOnce();
expect(fixture.replicateUnattendedByEvent).toHaveBeenCalledTimes(1);
expect(fixture.queueCheckFor).toHaveBeenCalledWith(path);
});
it("replaces an active same-file dialogue with only the newest waiting request", async () => {
const fixture = createDialogueConcurrencyHarness();
@@ -233,6 +681,40 @@ describe("interactive conflict resolution operations", () => {
await Promise.all([first, waiting]);
});
it("keeps another instance's active and waiting dialogues when disposal is repeated", async () => {
const oldInstance = createDialogueConcurrencyHarness();
const currentInstance = createDialogueConcurrencyHarness();
const oldActive = oldInstance.operations.resolveByUserInteraction(path, conflict);
await vi.waitFor(() => expect(oldInstance.dialogues).toHaveLength(1));
const oldWaiting = oldInstance.operations.resolveByUserInteraction(
"old-waiting.md" as FilePathWithPrefix,
conflict
);
const currentActive = currentInstance.operations.resolveByUserInteraction(path, conflict);
await vi.waitFor(() => expect(currentInstance.dialogues).toHaveLength(1));
const currentWaiting = currentInstance.operations.resolveByUserInteraction(
"current-waiting.md" as FilePathWithPrefix,
conflict
);
await Promise.resolve();
oldInstance.abort();
oldInstance.abort();
await expect(Promise.all([oldActive, oldWaiting])).resolves.toEqual([false, false]);
expect(oldInstance.dialogues).toHaveLength(1);
expect(currentInstance.dialogues).toHaveLength(1);
expect(currentInstance.emitEvent).not.toHaveBeenCalledWith(EVENT_CONFLICT_CANCELLED, path);
currentInstance.dialogues[0].finish(CANCELLED);
await vi.waitFor(() => expect(currentInstance.dialogues).toHaveLength(2));
expect(currentInstance.dialogues[1].filename).toBe("current-waiting.md");
currentInstance.dialogues[1].finish(CANCELLED);
await Promise.all([currentActive, currentWaiting]);
});
it("does not reopen an unchanged conflict after the user chooses Not now", async () => {
const { constructed, operations } = createOperations();
@@ -770,6 +1252,7 @@ describe("interactive conflict resolution feature composition", () => {
expect(completedOnUnload).toBe(true);
expect(dialogues).toHaveLength(1);
expect(fixture.getConflictedRevs).not.toHaveBeenCalled();
await fixture.handlers.onUnload?.();
});
@@ -23,6 +23,8 @@ import type { LiveSyncLocalDB } from "@vrtmrz/livesync-commonlib/compat/pouchdb/
import type { LiveSyncEventHub } from "@vrtmrz/livesync-commonlib/context";
import { fireAndForget } from "octagonal-wheels/promises";
import { withLock } from "octagonal-wheels/concurrency/lock";
import { withCancellation } from "octagonal-wheels/concurrency/cancellation";
import { withScope } from "octagonal-wheels/resource/scope";
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";
@@ -38,6 +40,7 @@ import {
export interface InteractiveConflictResolutionOperationsDependencies {
readonly events: Pick<LiveSyncEventHub, "emitEvent">;
readonly signal: AbortSignal;
readonly databaseFileAccess: Pick<DatabaseFileAccess, "getConflictedRevs" | "storeContent">;
readonly localDatabase: () => Pick<LiveSyncLocalDB, "getDBEntry" | "findAllDocs">;
readonly confirm: Pick<Confirm, "askSelectString" | "askInPopup">;
@@ -52,11 +55,11 @@ export interface InteractiveConflictResolutionOperationsDependencies {
}
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>;
/** Returns false if the requested conflict check was cancelled. */
readonly requestConflictResolution: (filename: FilePathWithPrefix) => Promise<boolean>;
readonly resolveByUserInteraction: (
filename: FilePathWithPrefix,
conflictCheckResult: diff_result
@@ -76,9 +79,18 @@ export function createInteractiveConflictResolutionOperations(
const latestRequestByFilename = new Map<FilePathWithPrefix, number>();
let nextRequestId = 0;
let activeDialogue: { filename: FilePathWithPrefix; dialogue: ConflictResolveDialogue } | undefined;
let disposed = false;
const clearPendingState = (): void => {
latestRequestByFilename.clear();
postponedConflictEpisodes.clear();
};
if (dependencies.signal.aborted) {
clearPendingState();
} else {
dependencies.signal.addEventListener("abort", clearPendingState, { once: true });
}
const invalidateWaitingResolution = (filename: FilePathWithPrefix): void => {
if (dependencies.signal.aborted) return;
// 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
@@ -87,21 +99,11 @@ export function createInteractiveConflictResolutionOperations(
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> => {
if (dependencies.signal.aborted) return undefined;
try {
const conflictCount = (await dependencies.databaseFileAccess.getConflictedRevs(filename)).length;
if (dependencies.signal.aborted) return undefined;
return conflictCount === 0 ? 0 : conflictCount + 1;
} catch (error) {
dependencies.log(`Could not inspect the conflict state of ${filename}`, LOG_LEVEL_VERBOSE);
@@ -111,9 +113,11 @@ export function createInteractiveConflictResolutionOperations(
};
const getActiveConflictMessages = async (): Promise<string[]> => {
if (dependencies.signal.aborted) return [];
const filename = dependencies.vault.getActiveFilePath();
if (!filename) return [];
const versionCount = await getConflictVersionCount(filename);
if (dependencies.signal.aborted) return [];
if (versionCount === 0) {
postponedConflictEpisodes.delete(filename);
return [];
@@ -132,39 +136,50 @@ export function createInteractiveConflictResolutionOperations(
};
const refreshConflictState = async (filename: FilePathWithPrefix): Promise<void> => {
if ((await getConflictVersionCount(filename)) === 0) {
if (dependencies.signal.aborted) return;
const versionCount = await getConflictVersionCount(filename);
if (dependencies.signal.aborted) return;
if (versionCount === 0) {
postponedConflictEpisodes.delete(filename);
}
dependencies.events.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
};
const requestConflictResolution = async (filename: FilePathWithPrefix): Promise<void> => {
const requestConflictResolution = async (filename: FilePathWithPrefix): Promise<boolean> => {
if (dependencies.signal.aborted) return false;
postponedConflictEpisodes.delete(filename);
dependencies.events.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
await dependencies.conflict.queueCheckFor(filename);
await dependencies.conflict.ensureAllProcessed();
if (dependencies.signal.aborted) return false;
return 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 withLock(dialogueSerialisationKey, async () => {
if (disposed || latestRequestByFilename.get(filename) !== requestId) {
return false;
if (dependencies.signal.aborted) return false;
return await withScope(undefined, async (request) => {
if (dependencies.signal.aborted) return false;
const requestId = ++nextRequestId;
if (activeDialogue?.filename === filename) {
dependencies.events.emitEvent(EVENT_CONFLICT_CANCELLED, filename);
activeDialogue = undefined;
}
try {
latestRequestByFilename.set(filename, requestId);
request.defer(() => {
if (latestRequestByFilename.get(filename) === requestId) {
latestRequestByFilename.delete(filename);
}
});
// 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 withLock(dialogueSerialisationKey, async () => {
if (dependencies.signal.aborted || latestRequestByFilename.get(filename) !== requestId) {
return false;
}
if (postponedConflictEpisodes.has(filename)) {
dependencies.log(`Merge: Postponed ${filename}`, LOG_LEVEL_VERBOSE);
dependencies.events.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
@@ -175,13 +190,24 @@ export function createInteractiveConflictResolutionOperations(
activeDialogue = { filename, dialogue };
let selected: MergeDialogResult;
try {
dialogue.open();
selected = await dialogue.waitForResult();
selected = await withCancellation(dialogue, { signal: dependencies.signal }, async (waiting) => {
const cancelDialogue = (): void => {
if (activeDialogue?.dialogue === dialogue) {
dependencies.events.emitEvent(EVENT_CONFLICT_CANCELLED, filename);
}
};
waiting.signal.addEventListener("abort", cancelDialogue, { once: true });
waiting.defer(() => waiting.signal.removeEventListener("abort", cancelDialogue));
dialogue.open();
if (waiting.isCancelled()) cancelDialogue();
return await dialogue.waitForResult();
});
} finally {
if (activeDialogue?.dialogue === dialogue) {
activeDialogue = undefined;
}
}
if (dependencies.signal.aborted) return false;
if (selected === POSTPONED) {
postponedConflictEpisodes.add(filename);
dependencies.events.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
@@ -203,6 +229,7 @@ export function createInteractiveConflictResolutionOperations(
if (!testDoc._conflicts || testDoc._conflicts.length === 0) {
dependencies.log(`Merge: Nothing to do ${filename}`, LOG_LEVEL_VERBOSE);
await refreshConflictState(filename);
if (dependencies.signal.aborted) return false;
return false;
}
if (
@@ -214,6 +241,7 @@ export function createInteractiveConflictResolutionOperations(
LOG_LEVEL_INFO
);
await refreshConflictState(filename);
if (dependencies.signal.aborted) return false;
await dependencies.conflict.queueCheckFor(filename);
return false;
}
@@ -224,15 +252,18 @@ export function createInteractiveConflictResolutionOperations(
// 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))) {
const contentStored = await dependencies.databaseFileAccess.storeContent(filename, p);
if (!contentStored) {
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
) {
const deletionResult = await dependencies.conflict.resolveByDeletingRevision(
filename,
delRev,
"UI Concatenated"
);
if (deletionResult == MISSING_OR_ERROR) {
dependencies.log(
`Concatenated saved, but cannot delete conflicted revisions: ${filename}, (${displayRev(delRev)})`,
LOG_LEVEL_NOTICE
@@ -244,10 +275,12 @@ export function createInteractiveConflictResolutionOperations(
(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
) {
const deletionResult = await dependencies.conflict.resolveByDeletingRevision(
filename,
toDelete,
"UI Selected"
);
if (deletionResult == MISSING_OR_ERROR) {
dependencies.log(`Merge: Something went wrong: ${filename}, (${toDelete})`, LOG_LEVEL_NOTICE);
return false;
}
@@ -270,17 +303,15 @@ export function createInteractiveConflictResolutionOperations(
// 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> => {
if (dependencies.signal.aborted) return false;
const notes: { id: DocumentID; path: FilePathWithPrefix; dispPath: string; mtime: number }[] = [];
for await (const doc of dependencies.localDatabase().findAllDocs({ conflicts: true })) {
if (dependencies.signal.aborted) return false;
if (!("_conflicts" in doc)) continue;
notes.push({
id: doc._id,
@@ -289,6 +320,7 @@ export function createInteractiveConflictResolutionOperations(
mtime: doc.mtime,
});
}
if (dependencies.signal.aborted) return false;
notes.sort((a, b) => b.mtime - a.mtime);
const notesList = notes.map((e) => e.dispPath);
if (notesList.length == 0) {
@@ -298,6 +330,7 @@ export function createInteractiveConflictResolutionOperations(
return false;
}
const target = await dependencies.confirm.askSelectString("File to resolve conflict", notesList);
if (dependencies.signal.aborted) return false;
if (target) {
const targetItem = notes.find((e) => e.dispPath == target)!;
await requestConflictResolution(targetItem.path);
@@ -308,19 +341,22 @@ export function createInteractiveConflictResolutionOperations(
const allConflictCheck = async (): Promise<void> => {
let notifyIfEmpty = true;
while (await pickFileForResolve(notifyIfEmpty)) {
while (!dependencies.signal.aborted && (await pickFileForResolve(notifyIfEmpty))) {
notifyIfEmpty = false;
}
};
const scanStartupIssues = async (): Promise<boolean> => {
if (dependencies.signal.aborted) return false;
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 (dependencies.signal.aborted) return false;
if (!("_conflicts" in doc)) continue;
notes.push({ path: dependencies.path.getPath(doc), mtime: doc.mtime });
}
if (dependencies.signal.aborted) return false;
if (notes.length > 0) {
dependencies.confirm.askInPopup(
`conflicting-detected-on-safety`,
@@ -351,7 +387,6 @@ export function createInteractiveConflictResolutionOperations(
};
return {
dispose,
invalidateWaitingResolution,
getActiveConflictMessages,
refreshConflictState,