mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-11-06 00:08:23 +00:00
Stop conflict work after disposal and verify reload preservation
Stop pending dialogue and selected-resolution continuations after disposal, before they log or start further resolution work. Verify released log binders and retain real-Obsidian checks for content and revision history across reload.
This commit is contained in:
1 parent
d557298ceb
commit
6f8df8f731
4 files changed
+511
-50
No files matched your search
+272
-1
@@ -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,7 +32,10 @@ async function* documents(items: unknown[]) {
|
||||
}
|
||||
}
|
||||
|
||||
function createOperations(conflictedRevisions: string[] = ["2-right"]) {
|
||||
function createOperations(
|
||||
conflictedRevisions: string[] = ["2-right"],
|
||||
overrides: Partial<InteractiveConflictResolutionOperationsDependencies> = {}
|
||||
) {
|
||||
const context = createServiceContext();
|
||||
let dialogueResult: unknown = POSTPONED;
|
||||
const constructed = { value: 0 };
|
||||
@@ -75,6 +80,7 @@ function createOperations(conflictedRevisions: string[] = ["2-right"]) {
|
||||
};
|
||||
}),
|
||||
log,
|
||||
...overrides,
|
||||
} as unknown as InteractiveConflictResolutionOperationsDependencies);
|
||||
return {
|
||||
askSelectString,
|
||||
@@ -181,6 +187,271 @@ function createDialogueConcurrencyHarness() {
|
||||
}
|
||||
|
||||
describe("interactive conflict resolution operations", () => {
|
||||
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.operations.dispose();
|
||||
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("does not continue a selected conflict after the database read settles 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 sink = vi.fn();
|
||||
const addLog = bindableFunction<(message: unknown) => void>("addLog");
|
||||
const unregisterLog = 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.operations.dispose();
|
||||
unregisterLog();
|
||||
finishRead(false);
|
||||
|
||||
await expect(resolving).resolves.toBe(false);
|
||||
expect(sink).toHaveBeenCalledTimes(logsBeforeDisposal);
|
||||
expect(fixture.storeContent).not.toHaveBeenCalled();
|
||||
expect(fixture.resolveByDeletingRevision).not.toHaveBeenCalled();
|
||||
expect(fixture.replicateUnattendedByEvent).not.toHaveBeenCalled();
|
||||
expect(fixture.queueCheckFor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not log 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");
|
||||
const unregisterLog = 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.operations.dispose();
|
||||
unregisterLog();
|
||||
finishStore(false);
|
||||
|
||||
await expect(resolving).resolves.toBe(false);
|
||||
expect(sink).toHaveBeenCalledTimes(logsBeforeDisposal);
|
||||
expect(fixture.resolveByDeletingRevision).not.toHaveBeenCalled();
|
||||
expect(fixture.replicateUnattendedByEvent).not.toHaveBeenCalled();
|
||||
expect(fixture.queueCheckFor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not delete or requeue 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");
|
||||
const unregisterLog = 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.operations.dispose();
|
||||
unregisterLog();
|
||||
finishStore(true);
|
||||
|
||||
await expect(resolving).resolves.toBe(false);
|
||||
expect(sink).toHaveBeenCalledTimes(logsBeforeDisposal);
|
||||
expect(fixture.resolveByDeletingRevision).not.toHaveBeenCalled();
|
||||
expect(fixture.replicateUnattendedByEvent).not.toHaveBeenCalled();
|
||||
expect(fixture.queueCheckFor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not log 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");
|
||||
const unregisterLog = 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.operations.dispose();
|
||||
unregisterLog();
|
||||
finishDeletion(MISSING_OR_ERROR);
|
||||
|
||||
await expect(resolving).resolves.toBe(false);
|
||||
expect(sink).toHaveBeenCalledTimes(logsBeforeDisposal);
|
||||
expect(fixture.replicateUnattendedByEvent).not.toHaveBeenCalled();
|
||||
expect(fixture.queueCheckFor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not replicate or requeue 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");
|
||||
const unregisterLog = 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.operations.dispose();
|
||||
unregisterLog();
|
||||
finishDeletion(AUTO_MERGED);
|
||||
|
||||
await expect(resolving).resolves.toBe(false);
|
||||
expect(sink).toHaveBeenCalledTimes(logsBeforeDisposal);
|
||||
expect(fixture.replicateUnattendedByEvent).not.toHaveBeenCalled();
|
||||
expect(fixture.queueCheckFor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not log or emit a conflict refresh after a pending inspection rejects following 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");
|
||||
const unregisterLog = 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.operations.dispose();
|
||||
unregisterLog();
|
||||
rejectInspection(new Error("database unavailable"));
|
||||
|
||||
await expect(refreshing).resolves.toBeUndefined();
|
||||
expect(sink).toHaveBeenCalledTimes(logsBeforeDisposal);
|
||||
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");
|
||||
const unregisterLog = 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.operations.dispose();
|
||||
unregisterLog();
|
||||
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.operations.dispose();
|
||||
finishInspection(["2-right"]);
|
||||
|
||||
await expect(messages).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("replaces an active same-file dialogue with only the newest waiting request", async () => {
|
||||
const fixture = createDialogueConcurrencyHarness();
|
||||
|
||||
|
||||
@@ -100,10 +100,13 @@ export function createInteractiveConflictResolutionOperations(
|
||||
};
|
||||
|
||||
const getConflictVersionCount = async (filename: FilePathWithPrefix): Promise<number | undefined> => {
|
||||
if (disposed) return undefined;
|
||||
try {
|
||||
const conflictCount = (await dependencies.databaseFileAccess.getConflictedRevs(filename)).length;
|
||||
if (disposed) return undefined;
|
||||
return conflictCount === 0 ? 0 : conflictCount + 1;
|
||||
} catch (error) {
|
||||
if (disposed) return undefined;
|
||||
dependencies.log(`Could not inspect the conflict state of ${filename}`, LOG_LEVEL_VERBOSE);
|
||||
dependencies.log(error, LOG_LEVEL_VERBOSE);
|
||||
return undefined;
|
||||
@@ -111,9 +114,11 @@ export function createInteractiveConflictResolutionOperations(
|
||||
};
|
||||
|
||||
const getActiveConflictMessages = async (): Promise<string[]> => {
|
||||
if (disposed) return [];
|
||||
const filename = dependencies.vault.getActiveFilePath();
|
||||
if (!filename) return [];
|
||||
const versionCount = await getConflictVersionCount(filename);
|
||||
if (disposed) return [];
|
||||
if (versionCount === 0) {
|
||||
postponedConflictEpisodes.delete(filename);
|
||||
return [];
|
||||
@@ -132,7 +137,10 @@ export function createInteractiveConflictResolutionOperations(
|
||||
};
|
||||
|
||||
const refreshConflictState = async (filename: FilePathWithPrefix): Promise<void> => {
|
||||
if ((await getConflictVersionCount(filename)) === 0) {
|
||||
if (disposed) return;
|
||||
const versionCount = await getConflictVersionCount(filename);
|
||||
if (disposed) return;
|
||||
if (versionCount === 0) {
|
||||
postponedConflictEpisodes.delete(filename);
|
||||
}
|
||||
dependencies.events.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
|
||||
@@ -182,6 +190,7 @@ export function createInteractiveConflictResolutionOperations(
|
||||
activeDialogue = undefined;
|
||||
}
|
||||
}
|
||||
if (disposed) return false;
|
||||
if (selected === POSTPONED) {
|
||||
postponedConflictEpisodes.add(filename);
|
||||
dependencies.events.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
|
||||
@@ -196,6 +205,7 @@ export function createInteractiveConflictResolutionOperations(
|
||||
const testDoc = await dependencies
|
||||
.localDatabase()
|
||||
.getDBEntry(filename, { conflicts: true }, false, true, true);
|
||||
if (disposed) return false;
|
||||
if (testDoc === false) {
|
||||
dependencies.log(`Merge: Could not read ${filename} from the local database`, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
@@ -203,6 +213,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 (disposed) return false;
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
@@ -214,6 +225,7 @@ export function createInteractiveConflictResolutionOperations(
|
||||
LOG_LEVEL_INFO
|
||||
);
|
||||
await refreshConflictState(filename);
|
||||
if (disposed) return false;
|
||||
await dependencies.conflict.queueCheckFor(filename);
|
||||
return false;
|
||||
}
|
||||
@@ -224,15 +236,20 @@ 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 (disposed) return false;
|
||||
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 (disposed) return false;
|
||||
if (deletionResult == MISSING_OR_ERROR) {
|
||||
dependencies.log(
|
||||
`Concatenated saved, but cannot delete conflicted revisions: ${filename}, (${displayRev(delRev)})`,
|
||||
LOG_LEVEL_NOTICE
|
||||
@@ -244,10 +261,13 @@ 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 (disposed) return false;
|
||||
if (deletionResult == MISSING_OR_ERROR) {
|
||||
dependencies.log(`Merge: Something went wrong: ${filename}, (${toDelete})`, LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
@@ -266,6 +286,7 @@ export function createInteractiveConflictResolutionOperations(
|
||||
trigger: "merge",
|
||||
interaction: NO_INTERACTION,
|
||||
});
|
||||
if (disposed) return false;
|
||||
}
|
||||
// And, check it again.
|
||||
await dependencies.conflict.queueCheckFor(filename);
|
||||
|
||||
Reference in new issue
Block a user