mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-11-05 07:48:18 +00:00
Stop conflict checks and continuations after shutdown
This commit is contained in:
1 parent
6d811f80e1
commit
d923510698
4 files changed
+389
-8
No files matched your search
@@ -25,6 +25,8 @@ export interface ConflictCheckingDependencies {
|
||||
}
|
||||
|
||||
export interface ConflictCheckingHandlers {
|
||||
/** Stop new checks and logging from pending checks after shutdown. */
|
||||
readonly dispose: () => void;
|
||||
readonly queueCheckForIfOpen: (file: FilePathWithPrefix) => Promise<void>;
|
||||
readonly queueCheckFor: (file: FilePathWithPrefix) => Promise<void>;
|
||||
readonly ensureAllProcessed: () => Promise<boolean>;
|
||||
@@ -32,11 +34,14 @@ export interface ConflictCheckingHandlers {
|
||||
|
||||
/** Create conflict-checking handlers while retaining scheduling state privately. */
|
||||
export function createConflictCheckingHandlers(dependencies: ConflictCheckingDependencies): ConflictCheckingHandlers {
|
||||
let disposed = false;
|
||||
const conflictQueue = createConflictCheckQueue({
|
||||
async process(filename) {
|
||||
if (disposed) return;
|
||||
try {
|
||||
await dependencies.conflict.resolve(filename);
|
||||
} catch (error) {
|
||||
if (disposed) return;
|
||||
dependencies.log(`Conflict check failed: ${filename}`);
|
||||
dependencies.log(error, LOG_LEVEL_VERBOSE);
|
||||
}
|
||||
@@ -45,11 +50,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 (disposed) return;
|
||||
const path = file;
|
||||
if (dependencies.currentSettings().checkConflictOnlyOnOpen) {
|
||||
const activeFile: FilePath | undefined = dependencies.vault.getActiveFilePath();
|
||||
@@ -62,7 +69,9 @@ export function createConflictCheckingHandlers(dependencies: ConflictCheckingDep
|
||||
};
|
||||
|
||||
const queueCheckFor = async (file: FilePathWithPrefix): Promise<void> => {
|
||||
if (disposed) return;
|
||||
const optionalConflictResult = await dependencies.conflict.getOptionalConflictCheckMethod(file);
|
||||
if (disposed) return;
|
||||
if (optionalConflictResult == true) {
|
||||
// The conflict has been resolved by another process.
|
||||
return;
|
||||
@@ -79,8 +88,12 @@ export function createConflictCheckingHandlers(dependencies: ConflictCheckingDep
|
||||
};
|
||||
|
||||
const ensureAllProcessed = (): Promise<boolean> => conflictQueue.waitForIdle();
|
||||
const dispose = (): void => {
|
||||
disposed = true;
|
||||
};
|
||||
|
||||
return {
|
||||
dispose,
|
||||
queueCheckForIfOpen,
|
||||
queueCheckFor,
|
||||
ensureAllProcessed,
|
||||
|
||||
@@ -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,306 @@ describe("conflict resolution serviceFeature", () => {
|
||||
expect(harness.conflict.conflictProcessQueueCount.value).toBe(0);
|
||||
});
|
||||
|
||||
it("settles queued checks without logging 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();
|
||||
harness.addLog.mockImplementation(() => {
|
||||
throw new Error("Handler addLog not assigned");
|
||||
});
|
||||
pendingResolve.reject(new Error("Resolver rejected after unload"));
|
||||
|
||||
await expect(completion).resolves.toEqual({ status: "resolved", result: true });
|
||||
expect(resolve).toHaveBeenCalledOnce();
|
||||
expect(harness.addLog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not emit, log, or requeue a pending NOT_CONFLICTED result after the unload event", 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);
|
||||
harness.addLog.mockImplementation(() => {
|
||||
throw new Error("Handler addLog not assigned");
|
||||
});
|
||||
pendingMergeCheck.resolve({ ok: NOT_CONFLICTED });
|
||||
|
||||
await expect(completion).resolves.toEqual({ status: "resolved" });
|
||||
expect(emitEvent).not.toHaveBeenCalledWith(EVENT_CONFLICT_CANCELLED, path);
|
||||
expect(queueCheckFor).not.toHaveBeenCalled();
|
||||
expect(harness.addLog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not delete a conflict or requeue when auto-merge 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();
|
||||
harness.addLog.mockImplementation(() => {
|
||||
throw new Error("Handler addLog not assigned");
|
||||
});
|
||||
pendingStore.resolve(true);
|
||||
|
||||
await expect(completion).resolves.toEqual({ status: "resolved" });
|
||||
expect(harness.fileHandler.deleteRevisionFromDB).not.toHaveBeenCalled();
|
||||
expect(queueCheckFor).not.toHaveBeenCalled();
|
||||
expect(emitEvent).not.toHaveBeenCalledWith(EVENT_CONFLICT_CANCELLED, path);
|
||||
expect(harness.addLog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not continue revision deletion 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);
|
||||
harness.addLog.mockImplementation(() => {
|
||||
throw new Error("Handler addLog not assigned");
|
||||
});
|
||||
pendingDelete.resolve(true);
|
||||
|
||||
await expect(completion).resolves.toMatchObject({ status: "resolved" });
|
||||
expect(harness.databaseFileAccess.getConflictedRevs).not.toHaveBeenCalled();
|
||||
expect(harness.fileHandler.dbToStorage).not.toHaveBeenCalled();
|
||||
expect(emitEvent).not.toHaveBeenCalledWith(EVENT_CONFLICT_CANCELLED, path);
|
||||
expect(harness.addLog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not requeue or log after pending replication completes during unload", 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();
|
||||
harness.addLog.mockImplementation(() => {
|
||||
throw new Error("Handler addLog not assigned");
|
||||
});
|
||||
pendingReplication.resolve({ status: "completed" });
|
||||
|
||||
await expect(completion).resolves.toEqual({ status: "resolved" });
|
||||
expect(queueCheckFor).not.toHaveBeenCalled();
|
||||
expect(harness.addLog).not.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)(
|
||||
"settles conflict operations without side effects 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);
|
||||
}
|
||||
harness.addLog.mockImplementation(() => {
|
||||
throw new Error("Handler addLog not assigned");
|
||||
});
|
||||
|
||||
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.every(({ status }) => status === "fulfilled")).toBe(true);
|
||||
for (const operation of publicOperations) expect(operation).toHaveBeenCalledOnce();
|
||||
expect(optionalCheck).not.toHaveBeenCalled();
|
||||
expect(harness.tryAutoMerge).not.toHaveBeenCalled();
|
||||
expect(harness.databaseFileAccess.fetchEntryMeta).not.toHaveBeenCalled();
|
||||
expect(harness.databaseFileAccess.getConflictedRevs).not.toHaveBeenCalled();
|
||||
expect(harness.databaseFileAccess.storeContent).not.toHaveBeenCalled();
|
||||
expect(harness.fileHandler.deleteRevisionFromDB).not.toHaveBeenCalled();
|
||||
expect(harness.fileHandler.dbToStorage).not.toHaveBeenCalled();
|
||||
expect(harness.storageAccess.getFileNames).not.toHaveBeenCalled();
|
||||
expect(harness.services.replication.replicateUnattendedByEvent).not.toHaveBeenCalled();
|
||||
expect(emitEvent).not.toHaveBeenCalledWith(EVENT_CONFLICT_CANCELLED, path);
|
||||
expect(harness.addLog).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
unregisterOptionalCheck();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it("does not start a queued resolver 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(true);
|
||||
expect(resolve).toHaveBeenCalledTimes(10);
|
||||
expect(harness.conflict.conflictProcessQueueCount.value).toBe(0);
|
||||
});
|
||||
|
||||
it("stops bulk conflict resolution when its storage 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.addLog.mockImplementation(() => {
|
||||
throw new Error("Handler addLog not assigned");
|
||||
});
|
||||
pendingNames.resolve([path]);
|
||||
|
||||
await expect(operation).resolves.toBeUndefined();
|
||||
expect(harness.databaseFileAccess.fetchEntryMeta).not.toHaveBeenCalled();
|
||||
expect(harness.databaseFileAccess.getConflictedRevs).not.toHaveBeenCalled();
|
||||
expect(harness.fileHandler.deleteRevisionFromDB).not.toHaveBeenCalled();
|
||||
expect(harness.fileHandler.dbToStorage).not.toHaveBeenCalled();
|
||||
expect(harness.addLog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
@@ -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",
|
||||
@@ -47,6 +48,18 @@ 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 eventSubscriptions = new AbortController();
|
||||
const dispose = (): void => {
|
||||
checking.dispose();
|
||||
operations.dispose();
|
||||
eventSubscriptions.abort();
|
||||
};
|
||||
services.context.events.onceEvent(EVENT_PLUGIN_UNLOADED, dispose, { signal: eventSubscriptions.signal });
|
||||
services.appLifecycle.onUnload.addHandler(() => {
|
||||
dispose();
|
||||
return Promise.resolve(true);
|
||||
});
|
||||
});
|
||||
|
||||
export { createConflictCheckingHandlers } from "./checker";
|
||||
|
||||
@@ -55,6 +55,8 @@ export interface ConflictResolutionOperationsDependencies {
|
||||
}
|
||||
|
||||
export interface ConflictResolutionOperations {
|
||||
/** Stop continuations without cancelling file or database operations already started. */
|
||||
readonly dispose: () => void;
|
||||
readonly cancelActiveResolution: (filename: FilePathWithPrefix) => void;
|
||||
readonly resolveByDeletingRevision: (
|
||||
path: FilePathWithPrefix,
|
||||
@@ -68,13 +70,21 @@ export interface ConflictResolutionOperations {
|
||||
readonly resolveAllConflictedFilesByNewerOnes: () => Promise<void>;
|
||||
}
|
||||
|
||||
/** Create conflict-resolution operations which stop their continuations when disposed. */
|
||||
export function createConflictResolutionOperations(
|
||||
dependencies: ConflictResolutionOperationsDependencies
|
||||
): ConflictResolutionOperations {
|
||||
let disposed = false;
|
||||
const latestResolveRequestByFilename = new Map<FilePathWithPrefix, number>();
|
||||
let nextResolveRequestId = 0;
|
||||
|
||||
const dispose = (): void => {
|
||||
disposed = true;
|
||||
latestResolveRequestByFilename.clear();
|
||||
};
|
||||
|
||||
const cancelActiveResolution = (filename: FilePathWithPrefix): void => {
|
||||
if (disposed) return;
|
||||
latestResolveRequestByFilename.delete(filename);
|
||||
if (isLockAcquired(`conflict-resolve:${filename}`)) {
|
||||
dependencies.events.emitEvent(EVENT_CONFLICT_CANCELLED, filename);
|
||||
@@ -87,8 +97,11 @@ export function createConflictResolutionOperations(
|
||||
subTitle = "",
|
||||
showNotice = true
|
||||
): Promise<typeof MISSING_OR_ERROR | typeof AUTO_MERGED> => {
|
||||
if (disposed) return MISSING_OR_ERROR;
|
||||
const title = `Resolving ${subTitle ? `[${subTitle}]` : ""}:`;
|
||||
if (!(await dependencies.fileHandler.deleteRevisionFromDB(path, deleteRevision))) {
|
||||
const deleted = await dependencies.fileHandler.deleteRevisionFromDB(path, deleteRevision);
|
||||
if (disposed) return MISSING_OR_ERROR;
|
||||
if (!deleted) {
|
||||
dependencies.log(
|
||||
`${title} Could not delete conflicted revision ${displayRev(deleteRevision)} of ${path}`,
|
||||
LOG_LEVEL_NOTICE
|
||||
@@ -100,7 +113,9 @@ export function createConflictResolutionOperations(
|
||||
`${title} Conflicted revision has been deleted ${displayRev(deleteRevision)} ${path}`,
|
||||
LOG_LEVEL_INFO
|
||||
);
|
||||
if ((await dependencies.databaseFileAccess.getConflictedRevs(path)).length != 0) {
|
||||
const conflicts = await dependencies.databaseFileAccess.getConflictedRevs(path);
|
||||
if (disposed) return MISSING_OR_ERROR;
|
||||
if (conflicts.length != 0) {
|
||||
dependencies.log(`${title} some conflicts are left in ${path}`, LOG_LEVEL_INFO);
|
||||
return AUTO_MERGED;
|
||||
}
|
||||
@@ -111,7 +126,9 @@ export function createConflictResolutionOperations(
|
||||
// If no conflicts were found, write the resolved content to the storage.
|
||||
const documentPath = parseDocumentPath(path);
|
||||
const { path: filePath } = splitDocumentPath(documentPath);
|
||||
if (!(await dependencies.fileHandler.dbToStorage(documentPath, filePath, true))) {
|
||||
const reflected = await dependencies.fileHandler.dbToStorage(documentPath, filePath, true);
|
||||
if (disposed) return MISSING_OR_ERROR;
|
||||
if (!reflected) {
|
||||
dependencies.log(`Could not write the resolved content to the storage: ${path}`, LOG_LEVEL_NOTICE);
|
||||
return MISSING_OR_ERROR;
|
||||
}
|
||||
@@ -121,9 +138,11 @@ export function createConflictResolutionOperations(
|
||||
};
|
||||
|
||||
const checkConflictAndPerformAutoMerge = async (path: FilePathWithPrefix): Promise<diff_check_result> => {
|
||||
if (disposed) return CANCELLED;
|
||||
const ret = await dependencies
|
||||
.localDatabase()
|
||||
.tryAutoMerge(path, !dependencies.currentSettings().disableMarkdownAutoMerge);
|
||||
if (disposed) return CANCELLED;
|
||||
if ("ok" in ret) {
|
||||
return ret.ok;
|
||||
}
|
||||
@@ -131,7 +150,9 @@ export function createConflictResolutionOperations(
|
||||
if ("result" in ret) {
|
||||
const p = ret.result;
|
||||
// 1. Store the merged content to the storage.
|
||||
if (!(await dependencies.databaseFileAccess.storeContent(path, p))) {
|
||||
const stored = await dependencies.databaseFileAccess.storeContent(path, p);
|
||||
if (disposed) return CANCELLED;
|
||||
if (!stored) {
|
||||
dependencies.log(`Merged content cannot be stored:${path}`, LOG_LEVEL_NOTICE);
|
||||
return MISSING_OR_ERROR;
|
||||
}
|
||||
@@ -186,6 +207,7 @@ export function createConflictResolutionOperations(
|
||||
};
|
||||
|
||||
const resolve = async (filename: FilePathWithPrefix): Promise<void> => {
|
||||
if (disposed) return;
|
||||
const requestId = ++nextResolveRequestId;
|
||||
latestResolveRequestByFilename.set(filename, requestId);
|
||||
const serialisationKey = `conflict-resolve:${filename}`;
|
||||
@@ -196,12 +218,12 @@ export function createConflictResolutionOperations(
|
||||
dependencies.events.emitEvent(EVENT_CONFLICT_CANCELLED, filename);
|
||||
}
|
||||
return await withLock(serialisationKey, async () => {
|
||||
if (latestResolveRequestByFilename.get(filename) !== requestId) {
|
||||
if (disposed || latestResolveRequestByFilename.get(filename) !== requestId) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const conflictCheckResult = await checkConflictAndPerformAutoMerge(filename);
|
||||
if (latestResolveRequestByFilename.get(filename) !== requestId) {
|
||||
if (disposed || latestResolveRequestByFilename.get(filename) !== requestId) {
|
||||
return;
|
||||
}
|
||||
if (conflictCheckResult === NOT_CONFLICTED) {
|
||||
@@ -223,6 +245,7 @@ export function createConflictResolutionOperations(
|
||||
interaction: NO_INTERACTION,
|
||||
});
|
||||
}
|
||||
if (disposed) return;
|
||||
dependencies.log("[conflict] Automatically merged, but we have to check it again");
|
||||
await dependencies.conflict.queueCheckFor(filename);
|
||||
return;
|
||||
@@ -249,12 +272,15 @@ export function createConflictResolutionOperations(
|
||||
};
|
||||
|
||||
const resolveByNewest = async (filename: FilePathWithPrefix, showNotice = true): Promise<boolean> => {
|
||||
if (disposed) return false;
|
||||
const currentRev = await dependencies.databaseFileAccess.fetchEntryMeta(filename, undefined, true);
|
||||
if (disposed) return false;
|
||||
if (currentRev == false) {
|
||||
dependencies.log(`Could not get current revision of ${filename}`);
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
const revs = await dependencies.databaseFileAccess.getConflictedRevs(filename);
|
||||
if (disposed) return false;
|
||||
if (revs.length == 0) {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
@@ -278,25 +304,31 @@ export function createConflictResolutionOperations(
|
||||
}
|
||||
return diff;
|
||||
});
|
||||
if (disposed) return false;
|
||||
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++) {
|
||||
if (disposed) return false;
|
||||
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);
|
||||
}
|
||||
if (disposed) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
const resolveAllConflictedFilesByNewerOnes = async (): Promise<void> => {
|
||||
if (disposed) return;
|
||||
dependencies.log(`Resolving conflicts by newer ones`, LOG_LEVEL_NOTICE);
|
||||
|
||||
const files = await dependencies.storageAccess.getFileNames();
|
||||
if (disposed) return;
|
||||
|
||||
let i = 0;
|
||||
for (const file of files) {
|
||||
if (disposed) return;
|
||||
i++;
|
||||
if (i % 10 === 0)
|
||||
dependencies.log(
|
||||
@@ -306,10 +338,12 @@ export function createConflictResolutionOperations(
|
||||
);
|
||||
await resolveByNewest(file, false);
|
||||
}
|
||||
if (disposed) return;
|
||||
dependencies.log(`Done!`, LOG_LEVEL_NOTICE, "resolveAllConflictedFilesByNewerOnes");
|
||||
};
|
||||
|
||||
return {
|
||||
dispose,
|
||||
cancelActiveResolution,
|
||||
resolveByDeletingRevision,
|
||||
checkConflictAndPerformAutoMerge,
|
||||
|
||||
Reference in new issue
Block a user