mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-11-06 16:28:18 +00:00
Merge pull request #1315 from vrtmrz/refactor/shared-storage-path-resolution
Align storage paths, preserve shutdown logging, and guard WebApp identity
This commit is contained in:
35 files changed
+2491
-486
No files matched your search
@@ -0,0 +1,180 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { NodeStorageAdapter, fsPromises, os, path } from "@vrtmrz/livesync-commonlib/node";
|
||||
import type { FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { StandardIo } from "@vrtmrz/livesync-commonlib/context";
|
||||
import { stripAllPrefixes } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
|
||||
import { createDefaultCliSettings } from "@/apps/cli/cliSettingsDefaults";
|
||||
import { runCommand } from "@/apps/cli/commands/runCommand";
|
||||
import type { CLICommand, CLICommandContext } from "@/apps/cli/commands/types";
|
||||
import { main, type CliCommandRunner } from "@/apps/cli/main";
|
||||
|
||||
type StoredFileSnapshot = {
|
||||
id: string;
|
||||
revision: string;
|
||||
path: string;
|
||||
size: number;
|
||||
children: string[];
|
||||
content: string;
|
||||
};
|
||||
|
||||
describe("CLI NFC scan failure recovery", () => {
|
||||
const originalArgv = process.argv.slice();
|
||||
const originalExitCode = process.exitCode;
|
||||
const signalHandlers = new Map<"SIGINT" | "SIGTERM", Set<Parameters<typeof process.removeListener>[1]>>();
|
||||
let directory: string;
|
||||
let vaultPath: string;
|
||||
let settingsPath: string;
|
||||
let standardIo: StandardIo;
|
||||
|
||||
afterEach(async () => {
|
||||
for (const [signal, originalHandlers] of signalHandlers) {
|
||||
for (const handler of process.listeners(signal)) {
|
||||
if (!originalHandlers.has(handler)) process.removeListener(signal, handler);
|
||||
}
|
||||
}
|
||||
signalHandlers.clear();
|
||||
process.argv = originalArgv.slice();
|
||||
process.exitCode = originalExitCode;
|
||||
vi.restoreAllMocks();
|
||||
if (directory) await fsPromises.rm(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function start(command: CLICommand, runner: CliCommandRunner, exitCode: number) {
|
||||
process.exitCode = undefined;
|
||||
process.argv = [
|
||||
"node",
|
||||
"livesync-cli",
|
||||
path.join(directory, "db"),
|
||||
"--vault",
|
||||
vaultPath,
|
||||
"--settings",
|
||||
settingsPath,
|
||||
command,
|
||||
];
|
||||
await expect(main(standardIo, runner)).rejects.toThrow(`__EXIT__:${exitCode}`);
|
||||
}
|
||||
|
||||
async function snapshotFile(context: CLICommandContext, targetPath: string): Promise<StoredFileSnapshot> {
|
||||
for await (const doc of context.core.services.database.localDatabase.findAllNormalDocs({ conflicts: true }) || []) {
|
||||
if (stripAllPrefixes(doc.path) !== targetPath) continue;
|
||||
const file = await context.core.serviceModules.databaseFileAccess.fetch(targetPath as FilePathWithPrefix);
|
||||
if (!file || file.deleted) throw new Error(`Expected readable database content for ${targetPath}`);
|
||||
const body = file.body;
|
||||
if (body.type !== "text/plain") throw new Error(`Expected text database content for ${targetPath}`);
|
||||
return {
|
||||
id: doc._id,
|
||||
revision: doc._rev ?? "",
|
||||
path: doc.path,
|
||||
size: doc.size,
|
||||
children: [...doc.children],
|
||||
content: await body.text(),
|
||||
};
|
||||
}
|
||||
throw new Error(`Expected a stored Metadata document for ${targetPath}`);
|
||||
}
|
||||
|
||||
it("does not accept a partially enumerated Vault as complete and keeps its PouchDB Metadata", async () => {
|
||||
directory = await fsPromises.mkdtemp(path.join(os.tmpdir(), "livesync-cli-nfc-scan-integration-"));
|
||||
vaultPath = path.join(directory, "vault");
|
||||
settingsPath = path.join(directory, "settings.json");
|
||||
const databasePath = path.join(directory, "db");
|
||||
const knownPath = "notes/known.md";
|
||||
const blockedPath = "notes/blocked/new.md";
|
||||
const knownContent = "stored before the failed inventory\n";
|
||||
const blockedContent = "available after recovery\n";
|
||||
|
||||
await fsPromises.mkdir(path.join(vaultPath, "notes"), { recursive: true });
|
||||
await fsPromises.mkdir(databasePath);
|
||||
await fsPromises.writeFile(path.join(vaultPath, knownPath), knownContent);
|
||||
await fsPromises.writeFile(
|
||||
settingsPath,
|
||||
JSON.stringify({
|
||||
...createDefaultCliSettings(),
|
||||
isConfigured: true,
|
||||
pathNormalizationVersion: 1,
|
||||
})
|
||||
);
|
||||
standardIo = {
|
||||
readStdin: vi.fn(async () => ""),
|
||||
prompt: vi.fn(async () => ""),
|
||||
writeStdout: vi.fn(),
|
||||
writeStderr: vi.fn(),
|
||||
};
|
||||
for (const signal of ["SIGINT", "SIGTERM"] as const) {
|
||||
signalHandlers.set(signal, new Set(process.listeners(signal)));
|
||||
}
|
||||
vi.spyOn(process, "exit").mockImplementation((code) => {
|
||||
throw new Error(`__EXIT__:${code ?? 0}`);
|
||||
});
|
||||
|
||||
let seeded: StoredFileSnapshot | undefined;
|
||||
const seedMirror = vi.fn<CliCommandRunner>(async (options, context) => {
|
||||
const result = await runCommand(options, context);
|
||||
seeded = await snapshotFile(context, knownPath);
|
||||
return result;
|
||||
});
|
||||
await start("mirror", seedMirror, 0);
|
||||
expect(seedMirror).toHaveBeenCalledOnce();
|
||||
expect(seeded).toMatchObject({ path: knownPath, content: knownContent });
|
||||
|
||||
await fsPromises.mkdir(path.join(vaultPath, "notes", "blocked"));
|
||||
await fsPromises.writeFile(path.join(vaultPath, blockedPath), blockedContent);
|
||||
|
||||
const scanFailure = Object.assign(new Error("Storage permission denied"), { code: "EACCES" });
|
||||
const directoryReads: string[] = [];
|
||||
const parentDirectoryEntries: string[] = [];
|
||||
// Retain the prototype implementation before the spy replaces it; invoke it with each real adapter below.
|
||||
const originalReadDirectory = Reflect.get(
|
||||
NodeStorageAdapter.prototype,
|
||||
"readDirectory"
|
||||
);
|
||||
const readDirectory = vi.spyOn(NodeStorageAdapter.prototype, "readDirectory").mockImplementation(
|
||||
async function (this: NodeStorageAdapter, relativePath) {
|
||||
directoryReads.push(relativePath);
|
||||
if (relativePath === "notes/blocked") throw scanFailure;
|
||||
const entries = await originalReadDirectory.call(this, relativePath);
|
||||
if (relativePath === "notes") {
|
||||
parentDirectoryEntries.push(...(entries ?? []).map((entry) => entry.name));
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
);
|
||||
const failedMirror = vi.fn<CliCommandRunner>(async (options, context) => await runCommand(options, context));
|
||||
try {
|
||||
await start("mirror", failedMirror, 1);
|
||||
} finally {
|
||||
readDirectory.mockRestore();
|
||||
}
|
||||
|
||||
expect(failedMirror).not.toHaveBeenCalled();
|
||||
expect(directoryReads).toContain("");
|
||||
expect(directoryReads).toContain("notes");
|
||||
expect(directoryReads).toContain("notes/blocked");
|
||||
expect(parentDirectoryEntries).toContain("known.md");
|
||||
expect(parentDirectoryEntries).toContain("blocked");
|
||||
|
||||
let afterFailure: StoredFileSnapshot | undefined;
|
||||
const inspectAfterFailure = vi.fn<CliCommandRunner>(async (options, context) => {
|
||||
const result = await runCommand(options, context);
|
||||
afterFailure = await snapshotFile(context, knownPath);
|
||||
return result;
|
||||
});
|
||||
await start("ls", inspectAfterFailure, 0);
|
||||
expect(afterFailure).toEqual(seeded);
|
||||
expect(await fsPromises.readFile(path.join(vaultPath, knownPath), "utf8")).toBe(knownContent);
|
||||
expect(await fsPromises.readFile(path.join(vaultPath, blockedPath), "utf8")).toBe(blockedContent);
|
||||
|
||||
let recoveredKnown: StoredFileSnapshot | undefined;
|
||||
let recoveredNewFile: StoredFileSnapshot | undefined;
|
||||
const recoverMirror = vi.fn<CliCommandRunner>(async (options, context) => {
|
||||
const result = await runCommand(options, context);
|
||||
recoveredKnown = await snapshotFile(context, knownPath);
|
||||
recoveredNewFile = await snapshotFile(context, blockedPath);
|
||||
return result;
|
||||
});
|
||||
await start("mirror", recoverMirror, 0);
|
||||
expect(recoverMirror).toHaveBeenCalledOnce();
|
||||
expect(recoveredKnown).toEqual(seeded);
|
||||
expect(recoveredNewFile).toMatchObject({ path: blockedPath, content: blockedContent });
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,12 @@ import type { NodeFile, NodeFolder, NodeStat } from "./NodeTypes";
|
||||
import { path } from "@vrtmrz/livesync-commonlib/node";
|
||||
import type { CliDiagnosticReporter } from "@/apps/cli/cliOutput";
|
||||
import { findCaseInsensitiveFile } from "@/apps/caseInsensitiveFileLookup";
|
||||
import {
|
||||
createStoragePathResolver,
|
||||
parseFilePath,
|
||||
type StoragePath,
|
||||
type StoragePathResolver,
|
||||
} from "@vrtmrz/livesync-commonlib/storage";
|
||||
|
||||
/**
|
||||
* Complete file system adapter implementation for Node.js
|
||||
@@ -21,6 +27,7 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
|
||||
readonly vault: NodeVaultAdapter;
|
||||
|
||||
private fileCache = new Map<string, NodeFile>();
|
||||
private readonly isCaseInsensitiveSetting: () => boolean;
|
||||
|
||||
constructor(
|
||||
private basePath: string,
|
||||
@@ -31,6 +38,7 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
|
||||
this.path = new NodePathAdapter();
|
||||
this.typeGuard = new NodeTypeGuardAdapter();
|
||||
this.conversion = new NodeConversionAdapter();
|
||||
this.isCaseInsensitiveSetting = isCaseInsensitive;
|
||||
this.storage = new UnicodeResolvingNodeStorageAdapter(basePath, getPathNormalizationVersion, isCaseInsensitive);
|
||||
this.vault = new NodeVaultAdapter(this.storage);
|
||||
}
|
||||
@@ -39,8 +47,8 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
|
||||
return path.join(this.basePath, p);
|
||||
}
|
||||
|
||||
private normalisePath(p: FilePath | string): string {
|
||||
return this.path.normalisePath(p);
|
||||
private normalisePath(p: FilePath | string): FilePath {
|
||||
return parseFilePath(this.path.normalisePath(p));
|
||||
}
|
||||
|
||||
private async hasExactPathCase(pathStr: string): Promise<boolean> {
|
||||
@@ -122,15 +130,28 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
|
||||
|
||||
async refreshFile(p: string, caseInsensitive = false): Promise<NodeFile | null> {
|
||||
const pathStr = this.normalisePath(p);
|
||||
if (this.storage.isUnicodePathNormalizationEnabled) {
|
||||
try {
|
||||
const resolvedPath = await this.storage.resolveLogicalPath(pathStr, caseInsensitive, false);
|
||||
if (resolvedPath === null) {
|
||||
this.fileCache.delete(pathStr);
|
||||
return null;
|
||||
}
|
||||
return await this.refreshResolvedFile(resolvedPath);
|
||||
} catch (error) {
|
||||
this.fileCache.delete(pathStr);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const resolvedPath = this.storage.isUnicodePathNormalizationEnabled
|
||||
? await this.storage.resolveLogicalPath(pathStr, caseInsensitive, false)
|
||||
: pathStr;
|
||||
if (resolvedPath === null) {
|
||||
const stat = await this.storage.stat(pathStr, caseInsensitive);
|
||||
if (stat?.type !== "file") {
|
||||
this.fileCache.delete(pathStr);
|
||||
return null;
|
||||
}
|
||||
return await this.refreshResolvedFile(resolvedPath);
|
||||
const file: NodeFile = { path: pathStr, stat };
|
||||
this.fileCache.set(pathStr, file);
|
||||
return file;
|
||||
} catch (error) {
|
||||
if (error instanceof AmbiguousNormalisedPathError) throw error;
|
||||
this.fileCache.delete(pathStr);
|
||||
@@ -138,16 +159,16 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
|
||||
}
|
||||
}
|
||||
|
||||
private async refreshResolvedFile(resolvedPath: string): Promise<NodeFile | null> {
|
||||
private async refreshResolvedFile(resolvedPath: StoragePath): Promise<NodeFile | null> {
|
||||
try {
|
||||
const stat = await this.storage.statExact(resolvedPath);
|
||||
const stat = await this.storage.statResolved(resolvedPath);
|
||||
if (stat?.type !== "file") {
|
||||
this.fileCache.delete(resolvedPath);
|
||||
return null;
|
||||
}
|
||||
|
||||
const file: NodeFile = {
|
||||
path: resolvedPath as FilePath,
|
||||
path: resolvedPath,
|
||||
stat: {
|
||||
size: stat.size,
|
||||
mtime: stat.mtime,
|
||||
@@ -158,10 +179,8 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
|
||||
this.fileCache.set(resolvedPath, file);
|
||||
return file;
|
||||
} catch (error) {
|
||||
if (error instanceof AmbiguousNormalisedPathError) throw error;
|
||||
// Evict so a deleted file is not returned by subsequent cache scans.
|
||||
this.fileCache.delete(resolvedPath);
|
||||
return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,34 +188,83 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
|
||||
* Helper method to recursively scan directory and populate file cache
|
||||
*/
|
||||
async scanDirectory(relativePath: string = ""): Promise<void> {
|
||||
const resolvedPath = await this.storage.resolveLogicalPath(relativePath, false, false);
|
||||
if (resolvedPath !== null) await this.scanDirectoryInto(resolvedPath, this.fileCache);
|
||||
const files = new Map<string, NodeFile>();
|
||||
await this.scanDirectoryInto(relativePath, files);
|
||||
for (const [path, file] of files) this.fileCache.set(path, file);
|
||||
}
|
||||
|
||||
private async scanDirectoryInto(relativePath: string, files: Map<string, NodeFile>): Promise<void> {
|
||||
const resolver = this.createScanResolver();
|
||||
const filePath = parseFilePath(relativePath);
|
||||
if (resolver) {
|
||||
const resolvedPath =
|
||||
filePath === "" ? await resolver.resolveForWrite(filePath) : await resolver.resolveExisting(filePath);
|
||||
if (resolvedPath !== null) await this.scanResolvedDirectoryInto(resolver, resolvedPath, files);
|
||||
return;
|
||||
}
|
||||
await this.scanLegacyDirectoryInto(filePath, files);
|
||||
}
|
||||
|
||||
private createScanResolver(): StoragePathResolver | null {
|
||||
if (!this.storage.isUnicodePathNormalizationEnabled) return null;
|
||||
const caseInsensitive = this.isCaseInsensitiveSetting();
|
||||
return createStoragePathResolver({
|
||||
readDirectory: async (physicalDirectory) => await this.storage.readDirectory(physicalDirectory),
|
||||
policy: {
|
||||
pathNormalizationVersion: 1,
|
||||
handleFilenameCaseSensitive: !caseInsensitive,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async scanResolvedDirectoryInto(
|
||||
resolver: StoragePathResolver,
|
||||
relativePath: StoragePath,
|
||||
files: Map<string, NodeFile>
|
||||
): Promise<void> {
|
||||
const directoryStat = await this.storage.statResolved(relativePath);
|
||||
if (directoryStat === null) return;
|
||||
if (directoryStat.type !== "folder") {
|
||||
throw new Error(`Path is not a directory: ${this.resolvePath(relativePath)}`);
|
||||
}
|
||||
|
||||
const entries = await resolver.listResolvedDirectory(relativePath);
|
||||
if (entries === null) return;
|
||||
for (const entry of entries) {
|
||||
if (entry.kind === "directory") {
|
||||
await this.scanResolvedDirectoryInto(resolver, entry.path, files);
|
||||
continue;
|
||||
}
|
||||
|
||||
const stat = await this.storage.statResolved(entry.path);
|
||||
if (stat?.type !== "file") continue;
|
||||
const file: NodeFile = { path: entry.path, stat };
|
||||
files.set(entry.path, file);
|
||||
}
|
||||
}
|
||||
|
||||
private async scanLegacyDirectoryInto(relativePath: string, files: Map<string, NodeFile>): Promise<void> {
|
||||
const fullPath = this.resolvePath(relativePath);
|
||||
try {
|
||||
const directoryStat = this.storage.isUnicodePathNormalizationEnabled
|
||||
? await this.storage.statExact(relativePath)
|
||||
: await this.storage.stat(relativePath, false);
|
||||
const directoryStat = await this.storage.stat(relativePath, false);
|
||||
if (directoryStat?.type !== "folder") throw new Error(`Directory does not exist: ${fullPath}`);
|
||||
const entries = await this.storage.listExact(relativePath);
|
||||
const entries = await this.storage.list(relativePath, false);
|
||||
|
||||
for (const entryPath of entries.files) {
|
||||
const stat = await this.storage.statExact(entryPath);
|
||||
const stat = await this.storage.stat(entryPath, false);
|
||||
if (stat?.type !== "file") continue;
|
||||
const filePath = parseFilePath(entryPath);
|
||||
const file: NodeFile = {
|
||||
path: entryPath as FilePath,
|
||||
path: filePath,
|
||||
stat,
|
||||
};
|
||||
files.set(entryPath, file);
|
||||
files.set(filePath, file);
|
||||
}
|
||||
for (const entryPath of entries.folders) {
|
||||
await this.scanDirectoryInto(entryPath, files);
|
||||
await this.scanLegacyDirectoryInto(entryPath, files);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof AmbiguousNormalisedPathError) throw error;
|
||||
// Directory doesn't exist or is not readable
|
||||
this.reportDiagnostic(`Error scanning directory ${fullPath}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { fsPromises as fs, NodeStorageAdapter, os, path } from "@vrtmrz/livesync-commonlib/node";
|
||||
import { DEFAULT_SETTINGS, type FilePath } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { parseFilePath, type StoragePath } from "@vrtmrz/livesync-commonlib/storage";
|
||||
import {
|
||||
ServiceRebuilder,
|
||||
type ServiceRebuilderDependencies,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/serviceModules/Rebuilder";
|
||||
import { PathServiceCompat } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectablePathService";
|
||||
import { ServiceContext } from "@vrtmrz/livesync-commonlib/compat/services/base/ServiceBase";
|
||||
import { AmbiguousNormalisedPathError } from "./UnicodeResolvingNodeStorageAdapter";
|
||||
import { NodeFileSystemAdapter } from "./NodeFileSystemAdapter";
|
||||
|
||||
describe("NodeFileSystemAdapter file enumeration", () => {
|
||||
@@ -49,6 +51,17 @@ describe("NodeFileSystemAdapter file enumeration", () => {
|
||||
expect((await adapter.getFiles()).map((file) => file.path).sort()).toEqual(paths);
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"compatibility: lists other files in Legacy mode when one root name is unsupported",
|
||||
async () => {
|
||||
const { directory, adapter } = await createVault();
|
||||
await fs.writeFile(path.join(directory, "a:unrelated.md"), "unrelated local bytes");
|
||||
|
||||
expect((await adapter.getFiles()).map((file) => file.path).sort()).toEqual(paths);
|
||||
expect(await fs.readFile(path.join(directory, "a:unrelated.md"), "utf8")).toBe("unrelated local bytes");
|
||||
}
|
||||
);
|
||||
|
||||
it("excludes a deleted file after its cache entry is refreshed", async () => {
|
||||
const { directory, adapter } = await createVault();
|
||||
await adapter.getFiles();
|
||||
@@ -219,6 +232,120 @@ describe("NodeFileSystemAdapter Unicode path resolution", () => {
|
||||
);
|
||||
}
|
||||
|
||||
it("propagates path-resolution failures when refreshing a file", async () => {
|
||||
const directory = await createVault();
|
||||
const adapter = createNormalisingAdapter(directory);
|
||||
const error = Object.assign(new Error("Storage permission denied"), { code: "EACCES" });
|
||||
vi.spyOn(adapter.storage, "resolveLogicalPath").mockRejectedValue(error);
|
||||
|
||||
await expect(adapter.refreshFile("a.md")).rejects.toBe(error);
|
||||
});
|
||||
|
||||
it("propagates strict metadata failures when refreshing a resolved file", async () => {
|
||||
const directory = await createVault();
|
||||
await fs.writeFile(path.join(directory, "a.md"), "content");
|
||||
const adapter = createNormalisingAdapter(directory);
|
||||
const error = Object.assign(new Error("Storage permission denied"), { code: "EACCES" });
|
||||
vi.spyOn(adapter.storage, "statResolved").mockRejectedValue(error);
|
||||
|
||||
await expect(adapter.getAbstractFileByPath("a.md")).rejects.toBe(error);
|
||||
});
|
||||
|
||||
it("keeps resolved metadata reads strict when the identity mode changes during lookup", async () => {
|
||||
const directory = await createVault();
|
||||
await fs.writeFile(path.join(directory, "a.md"), "content");
|
||||
let version = 1;
|
||||
const adapter = new NodeFileSystemAdapter(directory, undefined, () => version);
|
||||
const error = Object.assign(new Error("Storage permission denied"), { code: "EACCES" });
|
||||
const resolve = adapter.storage.resolveLogicalPath.bind(adapter.storage);
|
||||
vi.spyOn(adapter.storage, "resolveLogicalPath").mockImplementation(async (...args) => {
|
||||
const result = await resolve(...args);
|
||||
version = 0;
|
||||
return result;
|
||||
});
|
||||
vi.spyOn(adapter.storage, "statResolved").mockRejectedValue(error);
|
||||
|
||||
await expect(adapter.getAbstractFileByPath("a.md")).rejects.toBe(error);
|
||||
});
|
||||
|
||||
it("propagates strict directory-read failures without reporting an incomplete scan", async () => {
|
||||
const directory = await createVault();
|
||||
const report = vi.fn();
|
||||
const adapter = new NodeFileSystemAdapter(directory, report, () => 1);
|
||||
const error = Object.assign(new Error("Storage permission denied"), { code: "EACCES" });
|
||||
vi.spyOn(adapter.storage, "readDirectory").mockRejectedValue(error);
|
||||
|
||||
await expect(adapter.getFiles()).rejects.toBe(error);
|
||||
expect(report).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not return or cache a partial NFC listing after a nested directory read fails", async () => {
|
||||
const directory = await createVault();
|
||||
const knownPath = "Known.md";
|
||||
const initialContent = "initial content";
|
||||
await fs.writeFile(path.join(directory, knownPath), initialContent);
|
||||
let normalizationVersion = 1;
|
||||
const adapter = new NodeFileSystemAdapter(directory, undefined, () => normalizationVersion, () => true);
|
||||
await adapter.scanDirectory();
|
||||
|
||||
const changedContent = "changed content after cache";
|
||||
await fs.writeFile(path.join(directory, knownPath), changedContent);
|
||||
await fs.mkdir(path.join(directory, "late-directory"));
|
||||
await fs.writeFile(path.join(directory, "late-directory", "later.md"), "later content");
|
||||
|
||||
const error = Object.assign(new Error("Storage permission denied"), { code: "EACCES" });
|
||||
const readDirectory = adapter.storage.readDirectory.bind(adapter.storage);
|
||||
const readDirectorySpy = vi.spyOn(adapter.storage, "readDirectory").mockImplementation(async (relativePath) => {
|
||||
if (relativePath === "late-directory") throw error;
|
||||
const entries = await readDirectory(relativePath);
|
||||
return relativePath === "" && entries !== null
|
||||
? [...entries].sort((left, right) => (left.name < right.name ? -1 : left.name > right.name ? 1 : 0))
|
||||
: entries;
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(adapter.getFiles()).rejects.toBe(error);
|
||||
await expect(adapter.scanDirectory()).rejects.toBe(error);
|
||||
} finally {
|
||||
readDirectorySpy.mockRestore();
|
||||
}
|
||||
|
||||
// Compatibility-mode case-insensitive lookup exposes the cache without
|
||||
// triggering another scan, so it can verify that the failed NFC scan did
|
||||
// not publish its already-enumerated file metadata.
|
||||
normalizationVersion = 0;
|
||||
const scanDirectory = vi.spyOn(adapter, "scanDirectory");
|
||||
await expect(adapter.getAbstractFileByPathInsensitive("known.md")).resolves.toMatchObject({
|
||||
path: knownPath,
|
||||
stat: { size: initialContent.length },
|
||||
});
|
||||
expect(scanDirectory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses one comparison policy snapshot for the whole NFC scan", async () => {
|
||||
const directory = await createVault();
|
||||
await fs.mkdir(path.join(directory, "Folder"));
|
||||
await fs.writeFile(path.join(directory, "Folder/Note.md"), "upper");
|
||||
await fs.writeFile(path.join(directory, "Folder/note.md"), "lower");
|
||||
let policyReads = 0;
|
||||
let normalizationReads = 0;
|
||||
const adapter = new NodeFileSystemAdapter(
|
||||
directory,
|
||||
undefined,
|
||||
() => {
|
||||
normalizationReads++;
|
||||
return 1;
|
||||
},
|
||||
() => ++policyReads > 1
|
||||
);
|
||||
|
||||
const files = await adapter.getFiles();
|
||||
|
||||
expect(files.map((file) => file.path).sort()).toEqual(["Folder/Note.md", "Folder/note.md"]);
|
||||
expect(normalizationReads).toBe(1);
|
||||
expect(policyReads).toBe(1);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempDirs.splice(0).map((directory) => fs.rm(directory, { recursive: true, force: true })));
|
||||
});
|
||||
@@ -253,13 +380,13 @@ describe("NodeFileSystemAdapter Unicode path resolution", () => {
|
||||
);
|
||||
for (const name of names) await fs.writeFile(path.join(directory, child, name), "content");
|
||||
const adapter = createNormalisingAdapter(directory, caseInsensitive);
|
||||
const list = vi.spyOn(NodeStorageAdapter.prototype, "list");
|
||||
const readDirectory = vi.spyOn(NodeStorageAdapter.prototype, "readDirectory");
|
||||
try {
|
||||
const files = await adapter.getFiles();
|
||||
expect(files.map((file) => file.path).sort()).toEqual(names.map((name) => `${child}/${name}`).sort());
|
||||
expect(list).toHaveBeenCalledTimes(3);
|
||||
expect(readDirectory).toHaveBeenCalledTimes(3);
|
||||
} finally {
|
||||
list.mockRestore();
|
||||
readDirectory.mockRestore();
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -306,12 +433,12 @@ describe("NodeFileSystemAdapter Unicode path resolution", () => {
|
||||
await fs.writeFile(path.join(directory, `${index}.md`), "content");
|
||||
}
|
||||
const adapter = createNormalisingAdapter(directory, caseInsensitive);
|
||||
const list = vi.spyOn(NodeStorageAdapter.prototype, "list");
|
||||
const readDirectory = vi.spyOn(NodeStorageAdapter.prototype, "readDirectory");
|
||||
try {
|
||||
expect(await adapter.getFiles()).toHaveLength(12);
|
||||
expect(list).toHaveBeenCalledTimes(1);
|
||||
expect(readDirectory).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
list.mockRestore();
|
||||
readDirectory.mockRestore();
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -379,11 +506,11 @@ describe("NodeFileSystemAdapter Unicode path resolution", () => {
|
||||
await fs.writeFile(path.join(directory, nfdName), "NFD", "utf8");
|
||||
const adapter = createNormalisingAdapter(directory);
|
||||
const exactFile = {
|
||||
path: nfcName as FilePath,
|
||||
path: parseFilePath(nfcName),
|
||||
stat: { ctime: 1, mtime: 2, size: 3, type: "file" as const },
|
||||
};
|
||||
|
||||
await expect(adapter.getAbstractFileByPath(nfcName)).rejects.toThrow(/ambiguous/i);
|
||||
await expect(adapter.getAbstractFileByPath(nfcName)).rejects.toBeInstanceOf(AmbiguousNormalisedPathError);
|
||||
await expect(adapter.vault.modify(exactFile, "changed")).rejects.toThrow(/ambiguous/i);
|
||||
await expect(adapter.vault.delete(exactFile)).rejects.toThrow(/ambiguous/i);
|
||||
|
||||
@@ -545,7 +672,8 @@ describe("NodeFileSystemAdapter Unicode path resolution", () => {
|
||||
const adapter = createNormalisingAdapter(directory);
|
||||
|
||||
await expect(adapter.getFiles()).resolves.toEqual([]);
|
||||
await expect(adapter.storage.statExact("linked/note.md")).rejects.toThrow(/symbolic links/i);
|
||||
await expect(adapter.storage.resolveLogicalPath(parseFilePath("linked/note.md"))).rejects.toThrow(/symbolic links/i);
|
||||
await expect(adapter.storage.statExact("linked/note.md" as StoragePath)).rejects.toThrow(/symbolic links/i);
|
||||
await expect(adapter.storage.write("linked/note.md", "changed")).rejects.toThrow(/symbolic links/i);
|
||||
await expect(fs.readFile(path.join(outside, "note.md"), "utf8")).resolves.toBe("outside");
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { FilePath, UXDataWriteOptions } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { UXDataWriteOptions } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { IVaultAdapter } from "@vrtmrz/livesync-commonlib/compat/serviceModules/adapters";
|
||||
import type { NodeFile, NodeFolder } from "./NodeTypes";
|
||||
import { NodeStorageAdapter } from "@vrtmrz/livesync-commonlib/node";
|
||||
import { UnicodeResolvingNodeStorageAdapter } from "./UnicodeResolvingNodeStorageAdapter";
|
||||
import { NodeStorageAdapter, UnicodeResolvingNodeStorageAdapter } from "@vrtmrz/livesync-commonlib/node";
|
||||
import { parseFilePath } from "@vrtmrz/livesync-commonlib/storage";
|
||||
|
||||
/**
|
||||
* Vault adapter implementation for Node.js
|
||||
@@ -56,7 +56,7 @@ export class NodeVaultAdapter implements IVaultAdapter<NodeFile> {
|
||||
|
||||
async rename(file: NodeFile, newPath: string): Promise<void> {
|
||||
await this.storage.rename(file.path, newPath);
|
||||
file.path = newPath as FilePath;
|
||||
file.path = parseFilePath(newPath);
|
||||
}
|
||||
|
||||
async delete(file: NodeFile | NodeFolder, force = false): Promise<void> {
|
||||
@@ -74,16 +74,17 @@ export class NodeVaultAdapter implements IVaultAdapter<NodeFile> {
|
||||
}
|
||||
|
||||
private async toNodeFile(path: string): Promise<NodeFile> {
|
||||
const resolvedPath =
|
||||
this.storage instanceof UnicodeResolvingNodeStorageAdapter
|
||||
? await this.storage.resolveLogicalPath(path, undefined, false)
|
||||
: path;
|
||||
if (resolvedPath === null) throw new Error(`Could not read created file metadata: ${path}`);
|
||||
const stat =
|
||||
this.storage instanceof UnicodeResolvingNodeStorageAdapter
|
||||
? await this.storage.statExact(resolvedPath)
|
||||
: await this.storage.stat(resolvedPath);
|
||||
const filePath = parseFilePath(path);
|
||||
if (this.storage instanceof UnicodeResolvingNodeStorageAdapter) {
|
||||
const resolvedPath = await this.storage.resolveLogicalPath(filePath, undefined, false);
|
||||
if (resolvedPath === null) throw new Error(`Could not read created file metadata: ${path}`);
|
||||
const stat = await this.storage.statExact(resolvedPath);
|
||||
if (stat?.type !== "file") throw new Error(`Could not read created file metadata: ${path}`);
|
||||
return { path: resolvedPath, stat };
|
||||
}
|
||||
|
||||
const stat = await this.storage.stat(filePath);
|
||||
if (stat?.type !== "file") throw new Error(`Could not read created file metadata: ${path}`);
|
||||
return { path: resolvedPath as FilePath, stat };
|
||||
return { path: filePath, stat };
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { fsPromises, os, path } from "@vrtmrz/livesync-commonlib/node";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { FilePath } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { parseFilePath } from "@vrtmrz/livesync-commonlib/storage";
|
||||
import { NodeFileSystemAdapter } from "./NodeFileSystemAdapter";
|
||||
import { NodeVaultAdapter } from "./NodeVaultAdapter";
|
||||
|
||||
@@ -11,7 +11,7 @@ describe("NodeVaultAdapter.rename", () => {
|
||||
await fsPromises.writeFile(path.join(directory, "Calculus.md"), "content", "utf8");
|
||||
const adapter = new NodeVaultAdapter(directory);
|
||||
const file = {
|
||||
path: "Calculus.md" as FilePath,
|
||||
path: parseFilePath("Calculus.md"),
|
||||
stat: { ctime: 1, mtime: 2, size: 7, type: "file" as const },
|
||||
};
|
||||
|
||||
@@ -37,7 +37,7 @@ describe("NodeVaultAdapter.rename", () => {
|
||||
);
|
||||
const adapter = new NodeVaultAdapter(directory);
|
||||
const file = {
|
||||
path: "source.md" as FilePath,
|
||||
path: parseFilePath("source.md"),
|
||||
stat: { ctime: 1, mtime: 2, size: 7, type: "file" as const },
|
||||
};
|
||||
|
||||
@@ -65,7 +65,7 @@ describe("NodeVaultAdapter.rename", () => {
|
||||
);
|
||||
const adapter = new NodeVaultAdapter(directory);
|
||||
const file = {
|
||||
path: "linked/victim.md" as FilePath,
|
||||
path: parseFilePath("linked/victim.md"),
|
||||
stat: { ctime: 1, mtime: 2, size: 6, type: "file" as const },
|
||||
};
|
||||
|
||||
|
||||
@@ -1,205 +1,2 @@
|
||||
import { NodeStorageAdapter, validateStoragePath } from "@vrtmrz/livesync-commonlib/node";
|
||||
|
||||
export class AmbiguousNormalisedPathError extends Error {
|
||||
constructor(path: string) {
|
||||
super(`Ambiguous path after Unicode normalisation: ${path}`);
|
||||
this.name = "AmbiguousNormalisedPathError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolves NFC-equivalent logical paths against current Node storage listings. */
|
||||
export class UnicodeResolvingNodeStorageAdapter extends NodeStorageAdapter {
|
||||
constructor(
|
||||
rootPath: string,
|
||||
private readonly getPathNormalizationVersion: () => number = () => 0,
|
||||
private readonly isCaseInsensitive: () => boolean = () => false
|
||||
) {
|
||||
super(rootPath);
|
||||
}
|
||||
|
||||
get isUnicodePathNormalizationEnabled(): boolean {
|
||||
return this.getPathNormalizationVersion() === 1;
|
||||
}
|
||||
|
||||
/** Read metadata for an entry already resolved or obtained from a storage listing. */
|
||||
async statExact(path: string) {
|
||||
return await super.trystat(path);
|
||||
}
|
||||
|
||||
/** Inspect a physical directory once before descending into its enumerated entries. */
|
||||
async listExact(path: string) {
|
||||
const entries = await super.list(path);
|
||||
if (this.isUnicodePathNormalizationEnabled) {
|
||||
const caseInsensitive = this.isCaseInsensitive();
|
||||
const names = new Map<string, string>();
|
||||
for (const entry of [...entries.files, ...entries.folders]) {
|
||||
const key = this.comparisonKey(entry, caseInsensitive);
|
||||
if (names.has(key)) throw new AmbiguousNormalisedPathError(entry);
|
||||
names.set(key, entry);
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
async resolveLogicalPath(
|
||||
path: string,
|
||||
caseInsensitive = this.isCaseInsensitive(),
|
||||
allowMissing = true
|
||||
): Promise<string | null> {
|
||||
if (!this.isUnicodePathNormalizationEnabled) return path;
|
||||
|
||||
validateStoragePath(path, true);
|
||||
if (path === "") return "";
|
||||
|
||||
const segments = path.split("/");
|
||||
let resolvedParent = "";
|
||||
|
||||
for (let index = 0; index < segments.length; index++) {
|
||||
const segment = segments[index];
|
||||
const entries = await super.list(resolvedParent);
|
||||
const candidates = [...entries.files, ...entries.folders];
|
||||
const requestedKey = this.comparisonKey(segment, caseInsensitive);
|
||||
const matches = candidates.filter((entryPath) => {
|
||||
const entryName = entryPath.slice(resolvedParent === "" ? 0 : resolvedParent.length + 1);
|
||||
return this.comparisonKey(entryName, caseInsensitive) === requestedKey;
|
||||
});
|
||||
|
||||
if (matches.length > 1) throw new AmbiguousNormalisedPathError(path);
|
||||
if (matches.length === 1) {
|
||||
resolvedParent = matches[0];
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!allowMissing) return null;
|
||||
const remainingPath = segments.slice(index).join("/");
|
||||
return resolvedParent === "" ? remainingPath : `${resolvedParent}/${remainingPath}`;
|
||||
}
|
||||
|
||||
return resolvedParent;
|
||||
}
|
||||
|
||||
override async exists(path: string, caseInsensitive = this.isCaseInsensitive()): Promise<boolean> {
|
||||
const resolvedPath = await this.resolveLogicalPath(path, caseInsensitive, false);
|
||||
if (resolvedPath === null) return false;
|
||||
return await super.exists(resolvedPath);
|
||||
}
|
||||
|
||||
override async trystat(path: string, caseInsensitive = this.isCaseInsensitive()) {
|
||||
const resolvedPath = await this.resolveLogicalPath(path, caseInsensitive, false);
|
||||
if (resolvedPath === null) return null;
|
||||
return await super.trystat(resolvedPath);
|
||||
}
|
||||
|
||||
override async stat(path: string, caseInsensitive = this.isCaseInsensitive()) {
|
||||
const resolvedPath = await this.resolveLogicalPath(path, caseInsensitive, false);
|
||||
if (resolvedPath === null) return null;
|
||||
return await super.trystat(resolvedPath);
|
||||
}
|
||||
|
||||
override async mkdir(path: string, caseInsensitive = this.isCaseInsensitive()): Promise<void> {
|
||||
const resolvedPath = await this.resolveLogicalPath(path, caseInsensitive, true);
|
||||
if (resolvedPath === null) throw new Error(`Could not resolve path: ${path}`);
|
||||
await super.mkdir(resolvedPath);
|
||||
}
|
||||
|
||||
override async remove(path: string, caseInsensitive = this.isCaseInsensitive()): Promise<void> {
|
||||
const resolvedPath = await this.resolveLogicalPath(path, caseInsensitive, false);
|
||||
await super.remove(resolvedPath ?? path);
|
||||
}
|
||||
|
||||
override async read(path: string, caseInsensitive = this.isCaseInsensitive()): Promise<string> {
|
||||
const resolvedPath = await this.resolveLogicalPath(path, caseInsensitive, false);
|
||||
return await super.read(resolvedPath ?? path);
|
||||
}
|
||||
|
||||
override async readBinary(path: string, caseInsensitive = this.isCaseInsensitive()): Promise<ArrayBuffer> {
|
||||
const resolvedPath = await this.resolveLogicalPath(path, caseInsensitive, false);
|
||||
return await super.readBinary(resolvedPath ?? path);
|
||||
}
|
||||
|
||||
override async write(
|
||||
path: string,
|
||||
data: string,
|
||||
options?: Parameters<NodeStorageAdapter["write"]>[2],
|
||||
caseInsensitive = this.isCaseInsensitive()
|
||||
): Promise<void> {
|
||||
const resolvedPath = await this.resolveLogicalPath(path, caseInsensitive, true);
|
||||
if (resolvedPath === null) throw new Error(`Could not resolve path: ${path}`);
|
||||
await super.write(resolvedPath, data, options);
|
||||
}
|
||||
|
||||
override async writeBinary(
|
||||
path: string,
|
||||
data: ArrayBuffer,
|
||||
options?: Parameters<NodeStorageAdapter["writeBinary"]>[2],
|
||||
caseInsensitive = this.isCaseInsensitive()
|
||||
): Promise<void> {
|
||||
const resolvedPath = await this.resolveLogicalPath(path, caseInsensitive, true);
|
||||
if (resolvedPath === null) throw new Error(`Could not resolve path: ${path}`);
|
||||
await super.writeBinary(resolvedPath, data, options);
|
||||
}
|
||||
|
||||
override async append(
|
||||
path: string,
|
||||
data: string,
|
||||
options?: Parameters<NodeStorageAdapter["append"]>[2],
|
||||
caseInsensitive = this.isCaseInsensitive()
|
||||
): Promise<void> {
|
||||
const resolvedPath = await this.resolveLogicalPath(path, caseInsensitive, true);
|
||||
if (resolvedPath === null) throw new Error(`Could not resolve path: ${path}`);
|
||||
await super.append(resolvedPath, data, options);
|
||||
}
|
||||
|
||||
override async rename(
|
||||
sourcePath: string,
|
||||
targetPath: string,
|
||||
caseInsensitive = this.isCaseInsensitive()
|
||||
): Promise<void> {
|
||||
const resolvedSource = await this.resolveLogicalPath(sourcePath, caseInsensitive, false);
|
||||
if (resolvedSource === null) return await super.rename(sourcePath, targetPath);
|
||||
|
||||
const resolvedTarget = await this.resolveLogicalPath(targetPath, caseInsensitive, true);
|
||||
if (resolvedTarget === null) throw new Error(`Could not resolve path: ${targetPath}`);
|
||||
|
||||
// Preserve explicit case-only or Unicode-spelling renames when the target resolves to the source.
|
||||
const sourceKey = this.comparisonKey(resolvedSource, caseInsensitive);
|
||||
const targetKey = this.comparisonKey(resolvedTarget, caseInsensitive);
|
||||
const target =
|
||||
sourceKey === targetKey && resolvedTarget === resolvedSource && targetPath !== resolvedSource
|
||||
? await this.resolveRenameTargetSpelling(targetPath, caseInsensitive)
|
||||
: resolvedTarget;
|
||||
|
||||
await super.rename(resolvedSource, target);
|
||||
}
|
||||
|
||||
override async list(basePath: string, caseInsensitive = this.isCaseInsensitive()) {
|
||||
const resolvedPath = await this.resolveLogicalPath(basePath, caseInsensitive, false);
|
||||
if (resolvedPath === null) return { files: [], folders: [] };
|
||||
return await super.list(resolvedPath);
|
||||
}
|
||||
|
||||
private async resolveRenameTargetSpelling(path: string, caseInsensitive: boolean): Promise<string> {
|
||||
validateStoragePath(path, false);
|
||||
const segments = path.split("/");
|
||||
const leaf = segments.pop();
|
||||
if (leaf === undefined) throw new Error(`Could not resolve path: ${path}`);
|
||||
const parentPath = segments.join("/");
|
||||
const resolvedParent = await this.resolveLogicalPath(parentPath, caseInsensitive, true);
|
||||
if (resolvedParent === null) throw new Error(`Could not resolve path: ${path}`);
|
||||
|
||||
const entries = await super.list(resolvedParent);
|
||||
const requestedKey = this.comparisonKey(leaf, caseInsensitive);
|
||||
const matches = [...entries.files, ...entries.folders].filter((entryPath) => {
|
||||
const entryName = entryPath.slice(resolvedParent === "" ? 0 : resolvedParent.length + 1);
|
||||
return this.comparisonKey(entryName, caseInsensitive) === requestedKey;
|
||||
});
|
||||
if (matches.length > 1) throw new AmbiguousNormalisedPathError(path);
|
||||
|
||||
return resolvedParent === "" ? leaf : `${resolvedParent}/${leaf}`;
|
||||
}
|
||||
|
||||
private comparisonKey(segment: string, caseInsensitive: boolean): string {
|
||||
const normalisedSegment = segment.normalize("NFC");
|
||||
return caseInsensitive ? normalisedSegment.toLowerCase() : normalisedSegment;
|
||||
}
|
||||
}
|
||||
export { UnicodeResolvingNodeStorageAdapter } from "@vrtmrz/livesync-commonlib/node";
|
||||
export { AmbiguousNormalisedPathError } from "@vrtmrz/livesync-commonlib/storage";
|
||||
@@ -28,7 +28,12 @@ Automated browser coverage uses Chromium. Other browsers, background execution,
|
||||
|
||||
## Path identity compatibility
|
||||
|
||||
WebApp currently supports **Legacy path identity** (`pathNormalizationVersion: 0`) only. **Unicode NFC path identity** (`1`) is not yet supported. Use legacy identity in WebApp's `.livesync/settings.json` and on every client sharing its CouchDB database or P2P group, including new CLI and Obsidian environments which default to NFC.
|
||||
WebApp currently supports **Legacy path identity** (`pathNormalizationVersion: 0`) only. **Unicode NFC path identity** (`1`) is not yet supported. WebApp rejects NFC settings before creating its services or opening its local database; it reports the reason and leaves the Vault files and settings unchanged. Use legacy identity in WebApp's `.livesync/settings.json` and on every client sharing its CouchDB database or P2P group, including new CLI and Obsidian environments which default to NFC.
|
||||
|
||||
The File System Access API can supply the directory listings and handles needed
|
||||
for NFC resolution. Commonlib provides that adapter, but WebApp has not yet
|
||||
integrated it with its file cache, inventory, and watcher behaviour. API-level
|
||||
support does not enable this application boundary automatically.
|
||||
|
||||
Changing the setting alone does not migrate an existing NFC database. Migration back to legacy identity requires a coordinated Rebuild and Fetch from a complete, authoritative Vault. Back up the Vault and database, and stop synchronisation on all clients before migrating. Keep WebApp disconnected until the migration is complete. See [Recovery and flag files](../../../docs/recovery.md) before rebuilding.
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getPathNormalizationVersion } from "@vrtmrz/livesync-commonlib/settings";
|
||||
import { useP2PSettingsPreparation } from "@/serviceFeatures/useP2PSettingsPreparation";
|
||||
/** Browser runtime for Self-hosted LiveSync over the File System Access API. */
|
||||
|
||||
@@ -55,6 +56,14 @@ const DEFAULT_SETTINGS: Partial<ObsidianLiveSyncSettings> = {
|
||||
autoSweepPluginsPeriodic: false,
|
||||
};
|
||||
|
||||
function assertWebAppPathIdentity(settings: Partial<ObsidianLiveSyncSettings> | null): void {
|
||||
if (getPathNormalizationVersion(settings ?? {}) === 1) {
|
||||
throw new Error(
|
||||
"WebApp supports Legacy path identity (pathNormalizationVersion: 0) only. Unicode NFC path identity is not supported."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export type WebAppRuntimeStatusKind = "info" | "warning" | "error" | "success";
|
||||
|
||||
export interface WebAppRuntimeOptions {
|
||||
@@ -100,6 +109,7 @@ export class WebAppRuntime {
|
||||
getSystemVaultName: () => this.rootHandle.name || "livesync-webapp",
|
||||
settings: {
|
||||
save: async (data) => {
|
||||
assertWebAppPathIdentity(data);
|
||||
try {
|
||||
await this.saveSettingsToFile(data);
|
||||
this.addLog("Saved to .livesync/settings.json", LOG_LEVEL_VERBOSE, "settings");
|
||||
@@ -108,15 +118,17 @@ export class WebAppRuntime {
|
||||
}
|
||||
},
|
||||
load: async () => {
|
||||
let data: Partial<ObsidianLiveSyncSettings> | null = null;
|
||||
try {
|
||||
const data = await this.loadSettingsFromFile();
|
||||
if (data) {
|
||||
this.addLog("Loaded from .livesync/settings.json", LOG_LEVEL_VERBOSE, "settings");
|
||||
return { ...DEFAULT_SETTINGS, ...data } as ObsidianLiveSyncSettings;
|
||||
}
|
||||
data = await this.loadSettingsFromFile();
|
||||
} catch {
|
||||
this.addLog("Failed to load settings; using defaults", LOG_LEVEL_NOTICE, "settings");
|
||||
}
|
||||
assertWebAppPathIdentity(data);
|
||||
if (data) {
|
||||
this.addLog("Loaded from .livesync/settings.json", LOG_LEVEL_VERBOSE, "settings");
|
||||
return { ...DEFAULT_SETTINGS, ...data } as ObsidianLiveSyncSettings;
|
||||
}
|
||||
return DEFAULT_SETTINGS as ObsidianLiveSyncSettings;
|
||||
},
|
||||
},
|
||||
@@ -205,6 +217,13 @@ export class WebAppRuntime {
|
||||
throw new Error("The WebApp runtime has already been started");
|
||||
}
|
||||
|
||||
try {
|
||||
assertWebAppPathIdentity(await this.loadSettingsFromFile());
|
||||
} catch (error) {
|
||||
this.showError(`Failed to start: ${String(error)}`);
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Create service context and hub
|
||||
this.serviceHub = createLiveSyncBrowserServiceHub<ServiceContext>(this.createServiceOptions());
|
||||
this.addLog("Self-hosted LiveSync WebApp", LOG_LEVEL_INFO, "initialise");
|
||||
|
||||
@@ -81,6 +81,10 @@ function createBufferedLog(entries: ReactiveSource<LogEntry[]>) {
|
||||
};
|
||||
}
|
||||
|
||||
function logToConsole(message: unknown, level?: number, key?: string) {
|
||||
console.log({ message, level, key });
|
||||
}
|
||||
|
||||
// Legacy Logger calls use this shared buffer; service logs use their owner's buffer.
|
||||
const recentGlobalLogEntries = reactiveSource<LogEntry[]>([]);
|
||||
setGlobalLogFunction(createBufferedLog(recentGlobalLogEntries));
|
||||
@@ -126,7 +130,7 @@ function redactLog(log: string) {
|
||||
const showDebugLog = false;
|
||||
export const MARK_DONE = "\u{2009}\u{2009}";
|
||||
export class ModuleLog extends AbstractObsidianModule {
|
||||
/** The host releases this connection after the complete asynchronous unload. */
|
||||
/** The host releases log display subscriptions after the complete asynchronous unload. */
|
||||
logResource!: OwnedResource<LogFunction>;
|
||||
private readonly recentLogEntries = reactiveSource<LogEntry[]>([]);
|
||||
private readonly displayLogEntries = () => {
|
||||
@@ -615,8 +619,10 @@ ${stringifyYaml(info)}
|
||||
}
|
||||
}
|
||||
override onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
|
||||
const log = createBufferedLog(this.recentLogEntries);
|
||||
const unregisterLog = services.API.addLog.setHandler(log);
|
||||
const bufferedLog = createBufferedLog(this.recentLogEntries);
|
||||
let logTarget: LogFunction = bufferedLog;
|
||||
const log: LogFunction = (message, level, key) => logTarget(message, level, key);
|
||||
services.API.addLog.setHandler(log);
|
||||
const collectGlobalLogs = () => {
|
||||
const entries = recentGlobalLogEntries.value;
|
||||
if (entries.length === 0) return;
|
||||
@@ -624,7 +630,7 @@ ${stringifyYaml(info)}
|
||||
this.recentLogEntries.value = [...this.recentLogEntries.value, ...entries];
|
||||
};
|
||||
this.logResource = createOwnedResource(log, () => {
|
||||
unregisterLog();
|
||||
logTarget = logToConsole;
|
||||
recentGlobalLogEntries.offChanged(collectGlobalLogs);
|
||||
this.recentLogEntries.offChanged(this.displayLogEntries);
|
||||
this.recentLogEntries.value = [];
|
||||
|
||||
@@ -107,25 +107,92 @@ describe("ModuleLog service connection", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("detaches both service registration and legacy subscription on disposal", async () => {
|
||||
it("keeps service logging callable after disposal and sends late logs to the console", async () => {
|
||||
const { services, module, sink } = await createHarness();
|
||||
await services.appLifecycle.onSettingLoaded();
|
||||
sink.mockClear();
|
||||
const consoleLog = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
||||
|
||||
await module.logResource.dispose();
|
||||
try {
|
||||
expect(() => services.API.addLog("late-service", 32, "late-key")).not.toThrow();
|
||||
expect(consoleLog).toHaveBeenCalledExactlyOnceWith({
|
||||
message: "late-service",
|
||||
level: 32,
|
||||
key: "late-key",
|
||||
});
|
||||
const cause = new Error("root cause");
|
||||
const error = Object.assign(new Error("late failure"), { cause });
|
||||
services.API.addLog(error, 128, "late-error");
|
||||
const errorEntry = consoleLog.mock.calls[1][0] as {
|
||||
message: Error & { cause?: unknown };
|
||||
level?: number;
|
||||
key?: string;
|
||||
};
|
||||
expect(errorEntry.message).toBe(error);
|
||||
expect(errorEntry.message.cause).toBe(cause);
|
||||
expect(errorEntry.level).toBe(128);
|
||||
expect(errorEntry.key).toBe("late-error");
|
||||
expect(sink).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
consoleLog.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("sends late circular service messages directly to the console", async () => {
|
||||
const { services, module, sink } = await createHarness();
|
||||
await services.appLifecycle.onSettingLoaded();
|
||||
sink.mockClear();
|
||||
const consoleLog = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
||||
const circular: { self?: unknown } = {};
|
||||
circular.self = circular;
|
||||
|
||||
try {
|
||||
await module.logResource.dispose();
|
||||
expect(() => services.API.addLog(circular, 32, "circular")).not.toThrow();
|
||||
const consoleEntry = consoleLog.mock.calls[0][0] as {
|
||||
message: unknown;
|
||||
level?: number;
|
||||
key?: string;
|
||||
};
|
||||
expect(consoleEntry.message).toBe(circular);
|
||||
expect(consoleEntry.level).toBe(32);
|
||||
expect(consoleEntry.key).toBe("circular");
|
||||
expect(sink).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
consoleLog.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("releases legacy and UI subscriptions while retaining late service console logging", async () => {
|
||||
const first = await createHarness();
|
||||
const second = await createHarness();
|
||||
await first.services.appLifecycle.onSettingLoaded();
|
||||
await second.services.appLifecycle.onSettingLoaded();
|
||||
first.sink.mockClear();
|
||||
second.sink.mockClear();
|
||||
const consoleLog = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
||||
|
||||
await first.module.logResource.dispose();
|
||||
await first.module.logResource.dispose();
|
||||
expect(() => first.services.API.addLog("retired")).toThrow("not assigned");
|
||||
second.services.API.addLog("active", 32, "service");
|
||||
first.Logger("legacy-after-retirement", 32, "legacy");
|
||||
expect(first.sink).not.toHaveBeenCalled();
|
||||
expect(second.sink.mock.calls).toEqual([
|
||||
["active", 32, "service"],
|
||||
["legacy-after-retirement", 32, "legacy"],
|
||||
]);
|
||||
await second.module.logResource.dispose();
|
||||
try {
|
||||
await first.module.logResource.dispose();
|
||||
await first.module.logResource.dispose();
|
||||
expect(() => first.services.API.addLog("retired", 32, "retired-key")).not.toThrow();
|
||||
expect(consoleLog).toHaveBeenCalledWith({
|
||||
message: "retired",
|
||||
level: 32,
|
||||
key: "retired-key",
|
||||
});
|
||||
second.services.API.addLog("active", 32, "service");
|
||||
first.Logger("legacy-after-retirement", 32, "legacy");
|
||||
expect(first.sink).not.toHaveBeenCalled();
|
||||
expect(second.sink.mock.calls).toEqual([
|
||||
["active", 32, "service"],
|
||||
["legacy-after-retirement", 32, "legacy"],
|
||||
]);
|
||||
await second.module.logResource.dispose();
|
||||
} finally {
|
||||
consoleLog.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves a replacement service handler registered when the old owner is disposed", async () => {
|
||||
@@ -143,20 +210,31 @@ describe("ModuleLog service connection", () => {
|
||||
expect(sink).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("releases a connection which never reached the settings-loaded phase", async () => {
|
||||
it("retires a connection which never reached the settings-loaded phase", async () => {
|
||||
const { services, module, sink, Logger } = await createHarness();
|
||||
services.API.addLog("pending");
|
||||
await module.logResource.dispose();
|
||||
Logger("after-disposal");
|
||||
expect(sink).not.toHaveBeenCalled();
|
||||
expect(() => services.API.addLog("retired")).toThrow("not assigned");
|
||||
const consoleLog = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
||||
try {
|
||||
await module.logResource.dispose();
|
||||
Logger("after-disposal");
|
||||
expect(() => services.API.addLog("retired", 32, "retired-key")).not.toThrow();
|
||||
expect(consoleLog).toHaveBeenCalledWith({
|
||||
message: "retired",
|
||||
level: 32,
|
||||
key: "retired-key",
|
||||
});
|
||||
expect(sink).not.toHaveBeenCalled();
|
||||
|
||||
const replacement = await createHarness();
|
||||
await replacement.services.appLifecycle.onSettingLoaded();
|
||||
const messages = replacement.sink.mock.calls.map(([message]) => message);
|
||||
expect(messages).toContain("after-disposal");
|
||||
expect(messages).not.toContain("pending");
|
||||
await replacement.module.logResource.dispose();
|
||||
const replacement = await createHarness();
|
||||
await replacement.services.appLifecycle.onSettingLoaded();
|
||||
const messages = replacement.sink.mock.calls.map(([message]) => message);
|
||||
expect(messages).toContain("after-disposal");
|
||||
expect(messages).not.toContain("pending");
|
||||
expect(messages).not.toContain("retired");
|
||||
await replacement.module.logResource.dispose();
|
||||
} finally {
|
||||
consoleLog.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["complete", "fail"] as const)(
|
||||
@@ -219,10 +297,20 @@ describe("ModuleLog service connection", () => {
|
||||
"moduleLiveSyncMain.logUnloadingPlugin"
|
||||
);
|
||||
}
|
||||
expect(() => services.API.addLog("retired")).toThrow("not assigned");
|
||||
sink.mockClear();
|
||||
Logger("after-unload");
|
||||
expect(sink).not.toHaveBeenCalled();
|
||||
const consoleLog = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
||||
try {
|
||||
expect(() => services.API.addLog("late-after-close", 32, "late-after-close")).not.toThrow();
|
||||
expect(consoleLog).toHaveBeenCalledWith({
|
||||
message: "late-after-close",
|
||||
level: 32,
|
||||
key: "late-after-close",
|
||||
});
|
||||
sink.mockClear();
|
||||
Logger("after-unload");
|
||||
expect(sink).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
consoleLog.mockRestore();
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
+485
-2
@@ -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,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { getStoragePathComparisonKey, type FilePath, type PathComparisonKey } from "@vrtmrz/livesync-commonlib/storage";
|
||||
|
||||
/**
|
||||
* Keeps storage from being changed through a name which the file system resolves but no directory lists.
|
||||
*
|
||||
@@ -20,7 +22,13 @@ export interface PathAliasGuardHost {
|
||||
/** How long the names which a directory listed are taken as still listed. A name which is missed is listed afresh. */
|
||||
const LISTING_LIFETIME_MS = 1_000;
|
||||
|
||||
const foldName = (name: string) => name.normalize("NFC").toLowerCase();
|
||||
// Host alias checks retain their fixed NFC and case-insensitive policy in both identity modes.
|
||||
// Original host names keep the host vocabulary; comparison does not apply rooted-storage input validation.
|
||||
const foldName = (name: string) =>
|
||||
getStoragePathComparisonKey(name as FilePath, {
|
||||
pathNormalizationVersion: 1,
|
||||
handleFilenameCaseSensitive: false,
|
||||
});
|
||||
|
||||
const nameOf = (entry: string) => {
|
||||
const segments = entry.split("/").filter((segment) => segment !== "");
|
||||
@@ -28,14 +36,14 @@ const nameOf = (entry: string) => {
|
||||
};
|
||||
|
||||
export class PathAliasGuard {
|
||||
private readonly listings = new Map<string, { names: Set<string>; listedAt: number }>();
|
||||
private readonly listings = new Map<string, { names: Set<PathComparisonKey<"storage">>; listedAt: number }>();
|
||||
|
||||
constructor(
|
||||
private readonly host: PathAliasGuardHost,
|
||||
private readonly now: () => number = Date.now
|
||||
) {}
|
||||
|
||||
private recentListing(directory: string): Set<string> | undefined {
|
||||
private recentListing(directory: string): Set<PathComparisonKey<"storage">> | undefined {
|
||||
const listing = this.listings.get(directory);
|
||||
if (!listing) return undefined;
|
||||
if (this.now() - listing.listedAt <= LISTING_LIFETIME_MS) return listing.names;
|
||||
@@ -43,7 +51,7 @@ export class PathAliasGuard {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private async listAfresh(directory: string): Promise<Set<string>> {
|
||||
private async listAfresh(directory: string): Promise<Set<PathComparisonKey<"storage">>> {
|
||||
const names = new Set((await this.host.list(directory)).map((entry) => foldName(nameOf(entry))));
|
||||
this.listings.set(directory, { names, listedAt: this.now() });
|
||||
return names;
|
||||
|
||||
@@ -15,7 +15,7 @@ import type { App, TFile } from "obsidian";
|
||||
import { afterAll, describe, expect, it, vi } from "vitest";
|
||||
import { ObsidianStorageAdapter } from "./ObsidianStorageAdapter";
|
||||
import { ObsidianVaultAdapter } from "./ObsidianVaultAdapter";
|
||||
import { createPathAliasGuard } from "./pathAliasGuard";
|
||||
import { createPathAliasGuard, PathAliasGuard } from "./pathAliasGuard";
|
||||
|
||||
const REFUSAL = "Refusing a path which reaches an existing entry by a name its folder does not list.";
|
||||
const CONFIG_DIRECTORY = ".obsidian";
|
||||
@@ -141,6 +141,30 @@ describe("paths which resolve to an existing entry under an unlisted name", () =
|
||||
expect(calls.trashFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows NFC and case equivalents of names exposed by the host listing", async () => {
|
||||
const folder = "Café".normalize("NFD");
|
||||
const guard = new PathAliasGuard({
|
||||
isIndexed: () => false,
|
||||
exists: async () => true,
|
||||
list: async (path) => (path === "" ? [folder] : [`${folder}/Note.md`]),
|
||||
});
|
||||
|
||||
await expect(guard.assertNoAlias("CAFÉ/note.md")).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it.each(["x:cache", "host\\name"])(
|
||||
"allows a listed target beside the unrelated host-listed name %s",
|
||||
async (unrelatedName) => {
|
||||
const guard = new PathAliasGuard({
|
||||
isIndexed: () => false,
|
||||
exists: async () => true,
|
||||
list: async () => [unrelatedName, "note.md"],
|
||||
});
|
||||
|
||||
await expect(guard.assertNoAlias("note.md")).resolves.toBeUndefined();
|
||||
}
|
||||
);
|
||||
|
||||
it("allows listed names, case-only differences, indexed paths, and new files", async () => {
|
||||
const { app, calls } = fakeApp();
|
||||
const adapter = new ObsidianStorageAdapter(app);
|
||||
|
||||
Reference in new issue
Block a user