Merge main into CouchDB cache compatibility changes

This commit is contained in:
vorotamoroz committed 2026-11-05 09:48:00 +00:00
commit c306146dcd
78 files changed
+6177 -929

No files matched your search

+7 -3
View File
@@ -418,7 +418,8 @@ The CLI uses the same settings format as the Obsidian plug-in. Create a `.livesy
#### Unicode path identity
The `pathNormalizationVersion` setting controls how file paths identify
Metadata documents when using CouchDB. An absent value or `0` keeps the legacy
Metadata documents. CouchDB support is released; the unreleased extension also
supports Object Storage and P2P. An absent value or `0` keeps the legacy
identity. Version `1` uses Unicode Normalization Form C (NFC) for Metadata IDs
and path comparison keys. It does not rewrite `Metadata.path`, rename existing
files, or change Chunks or file content. `handleFilenameCaseSensitive` remains
@@ -442,8 +443,11 @@ with `--force --write-settings`. Set the same value on each remaining CLI
client, then run `fetch` with `--force --write-settings`. Use **Overwrite
Server Data with This Device's Files** and **Reset Synchronisation on This
Device** for the corresponding Obsidian Rebuild and Fetch operations. Resolve
any ambiguous names before reconstruction. Version `1` is supported with
CouchDB only; do not resume a legacy client against the rebuilt database.
any ambiguous names before reconstruction. Object Storage Rebuild replaces the
remote journals as well as the local database. P2P Rebuild prepares only the
source local database; keep that peer online for the other devices to Fetch.
Every P2P participant must be updated and migrated. Do not resume a legacy
client against the rebuilt database.
### Command-line Reference
@@ -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 });
});
});
+92 -24
View File
@@ -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");
});
+15 -14
View File
@@ -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";
+1 -1
View File
@@ -1,5 +1,5 @@
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { createNewVaultSettings } from "@vrtmrz/livesync-commonlib/settings";
import { createNewVaultSettings } from "@/common/newVaultSettings.ts";
export function createDefaultCliSettings(): ObsidianLiveSyncSettings {
return {
@@ -7,10 +7,14 @@ describe("createDefaultCliSettings", () => {
const settings = createDefaultCliSettings();
const recommended = createNewVaultSettings();
expect(settings.pathNormalizationVersion).toBe(1);
expect(settings).toEqual({
...recommended,
pathNormalizationVersion: 1,
useIndexedDBAdapter: false,
isConfigured: false,
});
expect(recommended.pathNormalizationVersion).toBe(0);
expect(settings.remoteConfigurations).not.toBe(recommended.remoteConfigurations);
});
});
+20
View File
@@ -0,0 +1,20 @@
import {
REMOTE_COUCHDB,
REMOTE_MINIO,
type ObsidianLiveSyncSettings,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { RemoteConnectionProbe } from "@vrtmrz/livesync-commonlib/replication";
import { resolveInitialPathIdentity } from "@/common/initialPathIdentity";
/** Adopt an existing central remote only for the first explicit Fetch. */
export async function resolveInitialFetchPathIdentity(
settings: ObsidianLiveSyncSettings,
createProbe: () => Promise<RemoteConnectionProbe | undefined>
): Promise<0 | 1 | undefined> {
if (settings.isConfigured) return undefined;
const hasRemote =
(settings.remoteType === REMOTE_COUCHDB && !!settings.couchDB_URI && !!settings.couchDB_DBNAME) ||
(settings.remoteType === REMOTE_MINIO && !!settings.endpoint && !!settings.bucket);
if (!hasRemote) return undefined;
return await resolveInitialPathIdentity(await createProbe(), settings.pathNormalizationVersion);
}
@@ -0,0 +1,47 @@
import { describe, expect, it, vi } from "vitest";
import { REMOTE_COUCHDB, REMOTE_MINIO, REMOTE_P2P } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { createDefaultCliSettings } from "./cliSettingsDefaults";
import { resolveInitialFetchPathIdentity } from "./initialFetchPathIdentity";
function fixture() {
const settings = {
...createDefaultCliSettings(),
couchDB_URI: "https://db.test",
couchDB_DBNAME: "vault",
endpoint: "https://objects.test",
bucket: "vault",
};
const probe = {
check: vi.fn(),
getStatus: vi.fn(),
inspectPathIdentity: vi.fn(async () => ({
status: "configured" as const,
pathNormalizationVersion: 0 as const,
})),
dispose: vi.fn(async () => undefined),
};
return { settings, probe, createProbe: vi.fn(async () => probe) };
}
describe("initial CLI Fetch path identity", () => {
it.each([REMOTE_COUCHDB, REMOTE_MINIO])("adopts a legacy %s remote before the first Fetch", async (remoteType) => {
const { settings, probe, createProbe } = fixture();
settings.remoteType = remoteType;
await expect(resolveInitialFetchPathIdentity(settings, createProbe)).resolves.toBe(0);
expect(settings.pathNormalizationVersion).toBe(1);
expect(probe.dispose).toHaveBeenCalledOnce();
});
it("leaves an already configured device unchanged", async () => {
const { settings, createProbe } = fixture();
settings.isConfigured = true;
await expect(resolveInitialFetchPathIdentity(settings, createProbe)).resolves.toBeUndefined();
expect(createProbe).not.toHaveBeenCalled();
});
it.each(["unconfigured", "p2p"])("does not infer identity for %s settings", async (kind) => {
const { settings, createProbe } = fixture();
if (kind === "p2p") settings.remoteType = REMOTE_P2P;
else settings.couchDB_URI = "";
await expect(resolveInitialFetchPathIdentity(settings, createProbe)).resolves.toBeUndefined();
expect(createProbe).not.toHaveBeenCalled();
});
});
+40
View File
@@ -5,6 +5,7 @@ import { join } from "node:path";
import * as chokidar from "chokidar";
import { path } from "@vrtmrz/livesync-commonlib/node";
import * as offlineScanner from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner";
import { ReplicatorService } from "@vrtmrz/livesync-commonlib/compat/services/base/ReplicatorService";
import { ControlService } from "@vrtmrz/livesync-commonlib/compat/services/base/ControlService";
import {
AutoAccepting,
@@ -333,6 +334,45 @@ describe("CLI database preparation", () => {
expect(watch).not.toHaveBeenCalled();
});
it("retains the adopted initial Fetch identity without --write-settings", async () => {
await writeFile(
settingsPath,
JSON.stringify({
...createDefaultCliSettings(),
isConfigured: false,
couchDB_URI: "https://example.test",
couchDB_DBNAME: "vault",
couchDB_USER: "user",
couchDB_PASSWORD: "pass",
})
);
const dispose = vi.fn(async () => undefined);
const inspect = vi.spyOn(ReplicatorService.prototype, "createRemoteResource").mockResolvedValue({
check: vi.fn(),
getStatus: vi.fn(),
inspectPathIdentity: vi.fn(async () => ({ status: "configured", pathNormalizationVersion: 0 }) as const),
dispose,
});
await start(
"fetch",
async (_options, { core }) => {
expect(core.services.setting.currentSettings().pathNormalizationVersion).toBe(0);
return true;
},
0,
true
);
const persisted = JSON.parse(await readFile(settingsPath, "utf-8"));
expect(persisted.pathNormalizationVersion).toBe(0);
expect(dispose).toHaveBeenCalledOnce();
expect(inspect).toHaveBeenCalledOnce();
await start("ls", async (_options, { core }) => {
expect(core.services.setting.currentSettings().pathNormalizationVersion).toBe(0);
return false;
});
expect(inspect).toHaveBeenCalledOnce();
});
it.each(["rebuild", "fetch"] as const)("does not scan or mirror legacy Vault files before %s", async (command) => {
const storedPaths: string[] = [];
const runner = vi.fn<CliCommandRunner>(async (_options, { core }) => {
+19
View File
@@ -30,6 +30,8 @@ import type { ReplicationSchedulingControl } from "@/serviceFeatures/replication
import { createNodeStandardIo, fsPromises as fs, path } from "@vrtmrz/livesync-commonlib/node";
import type { StandardIo } from "@vrtmrz/livesync-commonlib/context";
import { writeStderrLine, writeStdoutLine } from "./cliOutput";
import { REMOTE_RESOURCE_KINDS } from "@vrtmrz/livesync-commonlib/replication";
import { resolveInitialFetchPathIdentity } from "./initialFetchPathIdentity";
import { createDefaultCliSettings } from "./cliSettingsDefaults";
import {
applyStoredSetting,
@@ -601,6 +603,22 @@ export async function main(
throw new Error("Replication scheduling was not provided during core feature composition.");
}
let initialFetchPathIdentity: 0 | 1 | undefined;
if (options.command === "fetch") {
core.services.appLifecycle.onSettingLoaded.addHandler(async () => {
const settings = core.services.setting.currentSettings();
const version = await resolveInitialFetchPathIdentity(settings, () =>
core.services.replicator.createRemoteResource(REMOTE_RESOURCE_KINDS.CONNECTION, settings)
);
if (version !== undefined) {
initialFetchPathIdentity = version;
await core.services.setting.applyPartial({ pathNormalizationVersion: version });
await core.services.setting.saveSettingData();
}
return true;
});
}
// Setup signal handlers for graceful shutdown
const shutdown = async (signal: string) => {
writeStdoutLine(standardIo);
@@ -802,6 +820,7 @@ export async function main(
: (JSON.parse(originalSettingsText) as ObsidianLiveSyncSettings);
durable.additionalSuffixOfDatabaseName =
core.services.setting.currentSettings().additionalSuffixOfDatabaseName;
if (initialFetchPathIdentity !== undefined) durable.pathNormalizationVersion = initialFetchPathIdentity;
settingsTextToCommit = JSON.stringify(durable, null, 2);
}
settingsTextToCommit ??= originalSettingsText;
+1 -1
View File
@@ -39,7 +39,7 @@
"dependencies": {
"@aws-crypto/sha256-js": "5.2.0",
"chokidar": "^4.0.0",
"minimatch": "^10.2.5",
"minimatch": "^11.0.3",
"octagonal-wheels": "0.1.56",
"pouchdb-adapter-http": "^9.0.0",
"pouchdb-adapter-leveldb": "^9.0.0",
+12
View File
@@ -26,6 +26,17 @@ WebApp requires:
Automated browser coverage uses Chromium. Other browsers, background execution, and remote types other than CouchDB remain experimental. Use feature detection rather than relying on a fixed browser-version list.
## Path identity compatibility
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.
## Use
Serve the built files over HTTPS, or from `localhost`, then open `webapp.html`.
@@ -44,6 +55,7 @@ For example, a minimal manually configured CouchDB connection includes:
"couchDB_DBNAME": "vault",
"isConfigured": true,
"liveSync": true,
"pathNormalizationVersion": 0,
"syncOnSave": true
}
```
+24 -5
View File
@@ -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");
+15
View File
@@ -0,0 +1,15 @@
import type { RemoteConnectionProbe } from "@vrtmrz/livesync-commonlib/replication";
import { withOwnedRemoteResource } from "./ownedRemoteResource";
/** Select initial settings from a read-only declaration before opening the local database. */
export async function resolveInitialPathIdentity(
probe: RemoteConnectionProbe | undefined,
newEnvironmentVersion: number
): Promise<0 | 1> {
if (!probe) throw new Error("The remote path identity cannot be inspected.");
return await withOwnedRemoteResource(probe, async (ownedProbe) => {
if (!ownedProbe.inspectPathIdentity) throw new Error("The remote path identity cannot be inspected.");
const result = await ownedProbe.inspectPathIdentity();
return result.status === "empty" ? (newEnvironmentVersion === 1 ? 1 : 0) : result.pathNormalizationVersion;
});
}
@@ -0,0 +1,39 @@
import { describe, expect, it, vi } from "vitest";
import type { PathIdentityInspection, RemoteConnectionProbe } from "@vrtmrz/livesync-commonlib/replication";
import { resolveInitialPathIdentity } from "./initialPathIdentity";
function makeProbe(result: PathIdentityInspection) {
return {
check: vi.fn(),
getStatus: vi.fn(),
inspectPathIdentity: vi.fn(async () => result),
dispose: vi.fn(async () => undefined),
} satisfies RemoteConnectionProbe;
}
describe("resolveInitialPathIdentity", () => {
it.each([0, 1] as const)("adopts existing version %s instead of the new-environment default", async (version) => {
const probe = makeProbe({ status: "configured", pathNormalizationVersion: version });
await expect(resolveInitialPathIdentity(probe, 1 - version)).resolves.toBe(version);
expect(probe.dispose).toHaveBeenCalledOnce();
});
it.each([0, 1] as const)("retains version %s only for a confirmed empty remote", async (version) => {
const probe = makeProbe({ status: "empty" });
await expect(resolveInitialPathIdentity(probe, version)).resolves.toBe(version);
expect(probe.dispose).toHaveBeenCalledOnce();
});
it("preserves inspection failures and disposes the probe", async () => {
const probe = makeProbe({ status: "empty" });
const error = new Error("remote unavailable");
probe.inspectPathIdentity.mockRejectedValue(error);
await expect(resolveInitialPathIdentity(probe, 1)).rejects.toBe(error);
expect(probe.dispose).toHaveBeenCalledOnce();
});
it("refuses an unavailable inspection capability without guessing", async () => {
const probe = makeProbe({ status: "empty" });
const { inspectPathIdentity: _inspect, ...unsupported } = probe;
await expect(resolveInitialPathIdentity(unsupported, 1)).rejects.toThrow("cannot be inspected");
expect(probe.dispose).toHaveBeenCalledOnce();
await expect(resolveInitialPathIdentity(undefined, 1)).rejects.toThrow("cannot be inspected");
});
});
@@ -7,6 +7,9 @@
* remove it from this map in the same change.
*/
export const liveSyncProvisionalEnglishMessages = {
"File identity": "File identity",
"Version 0 preserves legacy path identity; version 1 uses Unicode NFC. Physical file names stay unchanged. Update every device and stop synchronisation, then Rebuild from a complete authoritative Vault and Fetch on the other devices. With P2P, Rebuild prepares only the source device's local database. Resolve colliding file names first.":
"Version 0 preserves legacy path identity; version 1 uses Unicode NFC. Physical file names stay unchanged. Update every device and stop synchronisation, then Rebuild from a complete authoritative Vault and Fetch on the other devices. With P2P, Rebuild prepares only the source device's local database. Resolve colliding file names first.",
"Synchronisation protection": "Synchronisation protection",
"Synchronising your Vault": "Synchronising your Vault",
"Receiving changes from the server…": "Receiving changes from the server…",
+1 -1
View File
@@ -3167,7 +3167,7 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
"zh-tw": "若啟用「自動啟動 P2P 連線」,外掛啟動時會自動建立 P2P 連線。",
},
"If disabled(toggled), chunks will be split on the UI thread (Previous behaviour).": {
def: "If disabled(toggled), chunks will be split on the UI thread (Previous behaviour).",
def: "When enabled, chunks are split on the UI thread (the previous behaviour).",
es: "Si se desactiva, los chunks se dividen en el hilo de UI (comportamiento anterior)",
fr: "Si désactivé, les fragments seront découpés sur le thread UI (comportement précédent).",
he: "אם מכובה, נתחים יפוצלו בשרשור ממשק המשתמש (התנהגות קודמת).",
+1 -1
View File
@@ -368,7 +368,7 @@
"I understand the risks and will proceed without a backup.": "I understand the risks and will proceed without a backup.",
"I Understand, Overwrite Server": "I Understand, Overwrite Server",
"If \"Auto Start P2P Connection\" is enabled, the P2P connection will be started automatically when the plug-in launches.": "If \"Auto Start P2P Connection\" is enabled, the P2P connection will be started automatically when the plug-in launches.",
"If disabled(toggled), chunks will be split on the UI thread (Previous behaviour).": "If disabled(toggled), chunks will be split on the UI thread (Previous behaviour).",
"If disabled(toggled), chunks will be split on the UI thread (Previous behaviour).": "When enabled, chunks are split on the UI thread (the previous behaviour).",
"If enabled per-filed efficient customization sync will be used. We need a small migration when enabling this. And all devices should be updated to v0.23.18. Once we enabled this, we lost a compatibility with old versions.": "If enabled per-filed efficient customization sync will be used. We need a small migration when enabling this. And all devices should be updated to v0.23.18. Once we enabled this, we lost a compatibility with old versions.",
"If enabled, changes will be automatically broadcasted to all connected peers. Notified peers will start fetching the changes.": "If enabled, changes will be automatically broadcasted to all connected peers. Notified peers will start fetching the changes.",
"If enabled, chunks will be split into no more than 100 items. However, dedupe is slightly weaker.": "If enabled, chunks will be split into no more than 100 items. However, dedupe is slightly weaker.",
+1 -2
View File
@@ -437,8 +437,7 @@ If "Auto Start P2P Connection" is enabled, the P2P connection will be started au
If "Auto Start P2P Connection" is enabled, the P2P connection will be
started automatically when the plug-in launches.
If disabled(toggled), chunks will be split on the UI thread (Previous behaviour).:
If disabled(toggled), chunks will be split on the UI thread (Previous
behaviour).
When enabled, chunks are split on the UI thread (the previous behaviour).
If enabled per-filed efficient customization sync will be used. We need a small migration when enabling this. And all devices should be updated to v0.23.18. Once we enabled this, we lost a compatibility with old versions.:
If enabled per-filed efficient customization sync will be used. We need a
small migration when enabling this. And all devices should be updated to
+10
View File
@@ -0,0 +1,10 @@
import { createNewVaultSettings as createCommonlibNewVaultSettings } from "@vrtmrz/livesync-commonlib/settings";
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
/** Create settings for a new LiveSync environment. */
export function createNewVaultSettings(): ObsidianLiveSyncSettings {
return {
...createCommonlibNewVaultSettings(),
pathNormalizationVersion: 1,
};
}
+39
View File
@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import {
NEW_VAULT_SETTINGS,
SETTINGS_SCHEMA_DEFAULTS,
createNewVaultSettings as createCommonlibNewVaultSettings,
} from "@vrtmrz/livesync-commonlib/settings";
import { createNewVaultSettings } from "./newVaultSettings.ts";
describe("createNewVaultSettings", () => {
it("selects NFC path identity only for new host settings", () => {
expect(SETTINGS_SCHEMA_DEFAULTS.pathNormalizationVersion).toBe(0);
expect(NEW_VAULT_SETTINGS.pathNormalizationVersion).toBe(0);
expect(createCommonlibNewVaultSettings().pathNormalizationVersion).toBe(0);
expect(createNewVaultSettings().pathNormalizationVersion).toBe(1);
});
it("keeps remote configurations independent of shared and other new-Vault settings", () => {
const first = createNewVaultSettings();
const second = createNewVaultSettings();
const shared = createCommonlibNewVaultSettings();
expect(first.remoteConfigurations).not.toBe(second.remoteConfigurations);
expect(first.remoteConfigurations).not.toBe(shared.remoteConfigurations);
expect(first.pluginSyncExtendedSetting).not.toBe(second.pluginSyncExtendedSetting);
expect(first.pluginSyncExtendedSetting).not.toBe(shared.pluginSyncExtendedSetting);
first.remoteConfigurations.example = {
id: "example",
name: "Example",
uri: "sls+http://localhost/",
isEncrypted: false,
};
expect(second.remoteConfigurations).toEqual({});
expect(shared.remoteConfigurations).toEqual({});
expect(NEW_VAULT_SETTINGS.remoteConfigurations).toEqual({});
expect(NEW_VAULT_SETTINGS.pluginSyncExtendedSetting).toEqual({});
});
});
+42 -1
View File
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { LOG_LEVEL_NOTICE, REMOTE_COUCHDB, REMOTE_MINIO } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { LOG_LEVEL_NOTICE, REMOTE_COUCHDB, REMOTE_MINIO, VERSIONING_DOCID } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { createNewVaultSettings } from "@vrtmrz/livesync-commonlib/settings";
@@ -21,6 +21,7 @@ const mocks = vi.hoisted(() => ({
objectStorage: [] as Array<{
host: unknown;
tryConnectRemote: ReturnType<typeof vi.fn>;
inspectRemotePathIdentity: ReturnType<typeof vi.fn>;
getRemoteStatus: ReturnType<typeof vi.fn>;
getRemotePreferredTweakValues: ReturnType<typeof vi.fn>;
getReplicationPBKDF2Salt: ReturnType<typeof vi.fn>;
@@ -64,6 +65,7 @@ vi.mock("@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalRe
LiveSyncJournalReplicator: class {
host: unknown;
tryConnectRemote = vi.fn();
inspectRemotePathIdentity = vi.fn();
getRemoteStatus = vi.fn();
getRemotePreferredTweakValues = vi.fn();
getReplicationPBKDF2Salt = vi.fn();
@@ -135,6 +137,45 @@ describe("replicator probe factories", () => {
expect(databaseClose).not.toHaveBeenCalled();
});
it("inspects CouchDB identity without encryption setup or remote writes and closes on failure", async () => {
const source = { ...createSettings(), encrypt: true };
const probe = await createCouchDBConnectionProbeFactory({} as never)(source);
const replicator = mocks.couchDB[0];
const get = vi.fn(async () => ({ _id: VERSIONING_DOCID, type: "versioninfo", version: 13, used_features: ["nfc-path-identity-v1"] }));
const close = vi.fn(async () => undefined);
replicator.connectRemoteCouchDBWithSetting.mockResolvedValue({ db: { get }, close });
await expect(probe.inspectPathIdentity!()).resolves.toEqual({
status: "configured",
pathNormalizationVersion: 1,
});
expect(replicator.connectRemoteCouchDBWithSetting).toHaveBeenCalledWith(
{ ...source, encrypt: false },
false,
false,
false
);
expect(close).toHaveBeenCalledOnce();
get.mockRejectedValueOnce(new Error("read unavailable"));
await expect(probe.inspectPathIdentity!()).rejects.toThrow("read unavailable");
expect(close).toHaveBeenCalledTimes(2);
expect(replicator.getReplicationPBKDF2Salt).not.toHaveBeenCalled();
});
it("delegates Journal identity inspection to the owned settings snapshot", async () => {
const source = createSettings();
const snapshot = { ...source };
const probe = await createObjectStorageConnectionProbeFactory({} as never)(source);
const replicator = mocks.objectStorage[0];
replicator.inspectRemotePathIdentity.mockResolvedValue({ status: "empty" });
source.bucket = "changed";
await expect(probe.inspectPathIdentity!()).resolves.toEqual({ status: "empty" });
expect(replicator.inspectRemotePathIdentity).toHaveBeenCalledWith(snapshot);
expect(replicator.tryConnectRemote).not.toHaveBeenCalled();
expect(replicator.getReplicationPBKDF2Salt).not.toHaveBeenCalled();
await probe.dispose();
expect(replicator.closeReplication).toHaveBeenCalledOnce();
});
it("maps a CouchDB connection error string and delegates status to the same snapshot", async () => {
const source = createSettings();
const snapshot = { ...source };
@@ -8,6 +8,7 @@ import {
LiveSyncCouchDBReplicator,
type LiveSyncCouchDBReplicatorEnv,
} from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator";
import { inspectDatabasePathIdentity } from "@vrtmrz/livesync-commonlib/replication";
import { LOG_LEVEL_NOTICE, Logger } from "@vrtmrz/livesync-commonlib/compat/common/logger";
import { LiveSyncJournalReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator";
import { createReplicatorDisposer, snapshotRemoteSettings } from "./shared";
@@ -51,6 +52,20 @@ function createCouchDBConnectionProbe(
await connection.close();
}
},
inspectPathIdentity: async () => {
const connection = await replicator.connectRemoteCouchDBWithSetting(
{ ...snapshot, encrypt: false },
replicator.isMobile(),
false,
false
);
if (typeof connection === "string") throw new Error(connection);
try {
return await inspectDatabasePathIdentity(connection.db);
} finally {
await connection.close();
}
},
getStatus: () => replicator.getRemoteStatus(snapshot),
dispose,
};
@@ -70,6 +85,7 @@ function createObjectStorageConnectionProbe(
return { ok: false, reason: error };
}
},
inspectPathIdentity: () => replicator.inspectRemotePathIdentity(snapshot),
getStatus: () => replicator.getRemoteStatus(snapshot),
dispose,
};
+10 -4
View File
@@ -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 = [];
+115 -27
View File
@@ -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();
}
}
);
});
@@ -1,5 +1,4 @@
import { ChunkAlgorithmNames } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { $msg } from "@/common/translation";
import type { SettingSpecGroup } from "./SettingSpec.ts";
export type AdvancedSettingSpecContext = {
@@ -24,18 +23,6 @@ export function createAdvancedSettingSpecGroups({
},
{ key: "customChunkSize", control: { type: "number", min: 0, allowZero: true } },
{ key: "strictContentCheckOnStartup", control: { type: "toggle" } },
{
key: "pathNormalizationVersion",
holdValue: true,
control: {
type: "numeric-dropdown",
options: () => ({
"0": $msg("Legacy path identity"),
"1": $msg("Unicode NFC path identity"),
}),
},
visible: isCouchDB,
},
],
},
{
@@ -8,7 +8,7 @@ export type GeneralSettingSpecContext = {
showVerboseLog: () => boolean;
};
/** Build the shared Appearance and Logging controls. */
/** Build the shared General Settings controls. */
export function createGeneralSettingSpecGroups({
showEditorStatusDetails,
showVerboseLog,
@@ -61,6 +61,22 @@ export function createGeneralSettingSpecGroups({
},
],
},
{
heading: "File identity",
items: [
{
key: "pathNormalizationVersion",
holdValue: true,
control: {
type: "numeric-dropdown",
options: () => ({
"0": $msg("Legacy path identity"),
"1": $msg("Unicode NFC path identity"),
}),
},
},
],
},
];
}
@@ -319,7 +319,7 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => {
).toEqual(["Copy the current settings to a Setup URI", "Show QR code"]);
});
it("keeps Appearance, Logging, and Extra menus inside General Settings", () => {
it("keeps Appearance, Logging, File identity, and Extra menus inside General Settings", () => {
const tab = createSettingsTab();
const definitions = tab.getSettingDefinitions();
const general = definitions.find(
@@ -332,7 +332,19 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => {
(item): item is SettingDefinitionPage => isPage(item) && item.name === "🎚️ Extra menus"
);
expect(generalPages?.map(({ name }) => name)).toEqual(["🎨 Appearance", "📝 Logging", "🎚️ Extra menus"]);
expect(generalPages?.map(({ name }) => name)).toEqual([
"🎨 Appearance",
"📝 Logging",
"🗂️ File identity",
"🎚️ Extra menus",
]);
const fileIdentity = generalPages?.find(({ name }) => name === "🗂️ File identity");
expect(
fileIdentity?.items?.flatMap((item) => ("control" in item && item.control ? [item.control.key] : []))
).toEqual(["pathNormalizationVersion"]);
expect(
fileIdentity?.items?.filter((item) => "action" in item && typeof item.action === "function")
).toHaveLength(1);
expect(
appearance?.items?.flatMap((item) => ("control" in item && item.control ? [item.control.key] : []))
).toEqual([
@@ -390,7 +402,7 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => {
const tab = createSettingsTab();
const pages = collectPages(tab.getSettingDefinitions());
expect(pages).toHaveLength(14);
expect(pages).toHaveLength(15);
expect(pages.map(({ name }) => name)).toEqual(
expect.arrayContaining(
createSettingsPageCatalogue()
@@ -892,9 +892,9 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
showEditorStatusDetails: () => this.isConfiguredAs("showStatusOnEditor", true),
showVerboseLog: () => this.isConfiguredAs("lessInformationInLog", false),
});
const [appearance, logging] = groups;
if (!appearance || !logging) {
throw new Error("General settings must define Appearance and Logging groups");
const [appearance, logging, fileIdentity] = groups;
if (!appearance || !logging || !fileIdentity) {
throw new Error("General settings must define Appearance, Logging, and File identity groups");
}
return this.createRootGroup("general-settings", [
{
@@ -907,6 +907,11 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
name: `📝 ${logging.heading}`,
items: logging.items,
},
{
type: "page",
name: `🗂️ ${fileIdentity.heading}`,
items: [this.createRebuildRequiredAction(), ...(fileIdentity.items ?? [])],
},
this.createExtraMenusPage(),
]);
}
@@ -1128,7 +1133,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
if (method === "remoteOnly" && this.isDirty("pathNormalizationVersion")) {
Logger(
$msg(
"CouchDB only. Version 0 preserves legacy path identity; version 1 uses Unicode NFC. Physical file names stay unchanged. Update every device and stop synchronisation, then Rebuild both databases from a complete authoritative Vault and Fetch on the other devices. Resolve colliding file names first. P2P and Object Storage do not support version 1."
"Version 0 preserves legacy path identity; version 1 uses Unicode NFC. Physical file names stay unchanged. Update every device and stop synchronisation, then Rebuild from a complete authoritative Vault and Fetch on the other devices. With P2P, Rebuild prepares only the source device's local database. Resolve colliding file names first."
),
LOG_LEVEL_NOTICE
);
@@ -581,11 +581,11 @@ describe("ObsidianLiveSyncSettingTab pending-setting initialisation", () => {
expect(Reflect.get(tab.editingSettings, "pathNormalizationVersion")).toBe(1);
expect(tab.isDirty("pathNormalizationVersion")).toBe(true);
expect(vi.mocked($msg)).toHaveBeenCalledWith(
"CouchDB only. Version 0 preserves legacy path identity; version 1 uses Unicode NFC. Physical file names stay unchanged. Update every device and stop synchronisation, then Rebuild both databases from a complete authoritative Vault and Fetch on the other devices. Resolve colliding file names first. P2P and Object Storage do not support version 1."
"Version 0 preserves legacy path identity; version 1 uses Unicode NFC. Physical file names stay unchanged. Update every device and stop synchronisation, then Rebuild from a complete authoritative Vault and Fetch on the other devices. With P2P, Rebuild prepares only the source device's local database. Resolve colliding file names first."
);
expect(loggerMocks.Logger).toHaveBeenCalledWith(
expect.stringContaining(
"Rebuild both databases from a complete authoritative Vault and Fetch on the other devices."
"Rebuild from a complete authoritative Vault and Fetch on the other devices."
),
LOG_LEVEL_NOTICE
);
@@ -692,6 +692,30 @@ describe("ObsidianLiveSyncSettingTab declarative settings boundary", () => {
return { tab, saveSettingData };
}
it("includes path identity once in the declarative General settings group", () => {
const { tab } = createSettingsTab();
const group = (
tab as unknown as {
createGeneralSettingsGroup: () => {
items: Array<{
name: string;
items?: Array<{ control?: { key: string }; action?: () => void; visible?: () => boolean }>;
}>;
};
}
).createGeneralSettingsGroup();
const fileIdentity = group.items.find(({ name }) => name === "🗂️ File identity");
expect(fileIdentity).toBeDefined();
const apply = fileIdentity?.items?.find(({ action }) => typeof action === "function");
expect(apply).toBeDefined();
Reflect.set(tab.editingSettings, "pathNormalizationVersion", 1);
expect(apply?.visible?.()).toBe(true);
expect(fileIdentity?.items?.flatMap(({ control }) => (control ? [control.key] : []))).toEqual([
"pathNormalizationVersion",
]);
});
it("awaits the stored passphrase before initialising the editing buffer", async () => {
const { tab } = createSettingsTab();
tab.resetEditingSettings();
@@ -43,7 +43,7 @@ describe("paneAdvanced", () => {
};
paneAdvanced.call(host as never, {} as HTMLElement, { addPanel } as never);
await vi.waitFor(() => expect(settingHarness.rendered).toHaveLength(11));
await vi.waitFor(() => expect(settingHarness.rendered).toHaveLength(10));
expect(addPanel.mock.calls.map(([, heading]) => heading)).toEqual([
"Memory cache",
@@ -56,7 +56,6 @@ describe("paneAdvanced", () => {
panelElements.get("Local Database Tweak"),
panelElements.get("Local Database Tweak"),
panelElements.get("Local Database Tweak"),
panelElements.get("Local Database Tweak"),
panelElements.get("Transfer Tweak"),
panelElements.get("Transfer Tweak"),
panelElements.get("Transfer Tweak"),
@@ -69,7 +68,6 @@ describe("paneAdvanced", () => {
"chunkSplitterVersion",
"customChunkSize",
"strictContentCheckOnStartup",
"pathNormalizationVersion",
"readChunksOnline",
"useOnlyLocalChunk",
"concurrencyOfReadChunksOnline",
@@ -0,0 +1,57 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { paneGeneral } from "./PaneGeneral.ts";
const settingHarness = vi.hoisted(() => ({
rendered: [] as unknown[],
}));
vi.mock("@/common/translation", () => ({
$msg: (key: string) => key,
$t: (key: string) => key,
}));
vi.mock("./LiveSyncSetting.ts", () => ({
LiveSyncSetting: class LiveSyncSetting {},
}));
vi.mock("./SettingSpec.ts", async (importOriginal) => {
const original = await importOriginal<typeof import("./SettingSpec.ts")>();
return {
...original,
renderLegacySettingSpec: vi.fn((_renderer: unknown, spec: unknown) => {
settingHarness.rendered.push(spec);
}),
};
});
afterEach(() => {
settingHarness.rendered.length = 0;
vi.clearAllMocks();
});
describe("paneGeneral", () => {
it("renders path identity once in the File identity panel and preserves its held value", async () => {
const addPanel = vi.fn((_parent: HTMLElement, heading: string) =>
Promise.resolve({ heading } as unknown as HTMLElement)
);
const host = {
isConfiguredAs: vi.fn(() => false),
};
paneGeneral.call(host as never, {} as HTMLElement, { addPanel } as never);
await vi.waitFor(() => expect(settingHarness.rendered).toHaveLength(14));
expect(addPanel.mock.calls.map(([, heading]) => heading)).toEqual([
"obsidianLiveSyncSettingTab.titleAppearance",
"obsidianLiveSyncSettingTab.titleLogging",
"File identity",
"obsidianLiveSyncSettingTab.titleExtraMenus",
]);
const pathIdentity = settingHarness.rendered.filter(
(spec) => (spec as { key: string }).key === "pathNormalizationVersion"
);
expect(pathIdentity).toHaveLength(1);
expect(pathIdentity[0]).toMatchObject({ holdValue: true });
expect((pathIdentity[0] as { visible?: () => boolean }).visible).toBeUndefined();
});
});
@@ -17,7 +17,7 @@ const rangeMessages = {
};
describe("Advanced setting specifications", () => {
it("lists only the eleven controls currently exposed by the Advanced page", () => {
it("lists only the ten controls currently exposed by the Advanced page", () => {
const groups = createAdvancedSettingSpecGroups({ isCouchDB: () => true });
expect(groups.map(({ heading }) => heading)).toEqual([
@@ -31,7 +31,6 @@ describe("Advanced setting specifications", () => {
"chunkSplitterVersion",
"customChunkSize",
"strictContentCheckOnStartup",
"pathNormalizationVersion",
"readChunksOnline",
"useOnlyLocalChunk",
"concurrencyOfReadChunksOnline",
@@ -52,13 +51,12 @@ describe("Advanced setting specifications", () => {
expect(spec.disabled).toBeUndefined();
});
it("keeps path identity and the four remote chunk controls CouchDB-only", () => {
it("keeps the four remote chunk controls CouchDB-only", () => {
let couchDB = false;
const groups = createAdvancedSettingSpecGroups({ isCouchDB: () => couchDB });
const conditional = groups.flatMap(({ items }) => items).filter(({ visible }) => visible !== undefined);
expect(conditional.map(({ key }) => key)).toEqual([
"pathNormalizationVersion",
"readChunksOnline",
"useOnlyLocalChunk",
"concurrencyOfReadChunksOnline",
@@ -70,18 +68,67 @@ describe("Advanced setting specifications", () => {
expect(conditional.every(({ visible }) => visible?.() === true)).toBe(true);
});
it("exposes CouchDB path identity as a two-choice numeric dropdown", () => {
let couchDB = false;
const spec = createAdvancedSettingSpecGroups({ isCouchDB: () => couchDB })
it("excludes transient settings-dialogue keys from the persisted key type", () => {
type OnDialogOverlap = Extract<keyof OnDialogSettings, PersistedSettingKey>;
expectTypeOf<OnDialogOverlap>().toEqualTypeOf<never>();
});
it("resolves translated Commonlib metadata for every explicitly exposed key", () => {
const specs = [
...createAdvancedSettingSpecGroups({ isCouchDB: () => true }),
...createGeneralSettingSpecGroups({
showEditorStatusDetails: () => true,
showVerboseLog: () => true,
}),
].flatMap(({ items }) => items);
for (const spec of specs) {
const metadata = getConfig(spec.key);
expect(metadata, spec.key).not.toBe(false);
if (!metadata) {
throw new Error(`Missing setting metadata for ${spec.key}`);
}
const definition = toObsidianSettingDefinition(spec, metadata, rangeMessages);
expect(definition.name).toBe(`${metadata.name}${statusDisplay(metadata.status)}`);
}
});
});
describe("General setting specifications", () => {
it("shares every General, Logging, and File identity control between the imperative and declarative renderers", () => {
const groups = createGeneralSettingSpecGroups({
showEditorStatusDetails: () => true,
showVerboseLog: () => true,
});
expect(groups.map(({ heading }) => heading)).toEqual(["Appearance", "Logging", "File identity"]);
expect(groups.flatMap(({ items }) => items.map(({ key }) => key))).toEqual([
"displayLanguage",
"showStatusOnEditor",
"showOnlyIconsOnEditor",
"showStatusOnStatusbar",
"hideFileWarningNotice",
"syncGuardEnabled",
"syncGuardMinimumFiles",
"networkWarningStyle",
"lessInformationInLog",
"showVerboseLog",
"pathNormalizationVersion",
]);
});
it("exposes path identity for every remote type as a reconstruction-required dropdown", () => {
const spec = createGeneralSettingSpecGroups({
showEditorStatusDetails: () => false,
showVerboseLog: () => false,
})
.flatMap(({ items }) => items)
.find(({ key }) => key === "pathNormalizationVersion");
if (!spec) throw new Error("Path identity mode is not exposed on the Advanced page");
if (!spec) throw new Error("Path identity mode is not exposed in General settings");
if (spec.control.type !== "numeric-dropdown") throw new Error("Expected a numeric dropdown");
expect(spec.control).toMatchObject({ type: "numeric-dropdown", options: expect.any(Function) });
expect(spec.visible?.()).toBe(false);
couchDB = true;
expect(spec.visible?.()).toBe(true);
expect(spec).toMatchObject({ key: "pathNormalizationVersion", holdValue: true });
expect(spec.visible).toBeUndefined();
expect(spec.control.options()).toEqual({
"0": "Legacy path identity",
"1": "Unicode NFC path identity",
@@ -102,54 +149,11 @@ describe("Advanced setting specifications", () => {
key: "pathNormalizationVersion",
options: {
holdValue: true,
onUpdate: expect.any(Function),
options: { "0": "Legacy path identity", "1": "Unicode NFC path identity" },
},
});
});
it("excludes transient settings-dialogue keys from the persisted key type", () => {
type OnDialogOverlap = Extract<keyof OnDialogSettings, PersistedSettingKey>;
expectTypeOf<OnDialogOverlap>().toEqualTypeOf<never>();
});
it("resolves translated Commonlib metadata for every explicitly exposed key", () => {
const specs = createAdvancedSettingSpecGroups({ isCouchDB: () => true }).flatMap(({ items }) => items);
for (const spec of specs) {
const metadata = getConfig(spec.key);
expect(metadata, spec.key).not.toBe(false);
if (!metadata) {
throw new Error(`Missing setting metadata for ${spec.key}`);
}
const definition = toObsidianSettingDefinition(spec, metadata, rangeMessages);
expect(definition.name).toBe(`${metadata.name}${statusDisplay(metadata.status)}`);
}
});
});
describe("General setting specifications", () => {
it("shares every General and Logging control between the imperative and declarative renderers", () => {
const groups = createGeneralSettingSpecGroups({
showEditorStatusDetails: () => true,
showVerboseLog: () => true,
});
expect(groups.map(({ heading }) => heading)).toEqual(["Appearance", "Logging"]);
expect(groups.flatMap(({ items }) => items.map(({ key }) => key))).toEqual([
"displayLanguage",
"showStatusOnEditor",
"showOnlyIconsOnEditor",
"showStatusOnStatusbar",
"hideFileWarningNotice",
"syncGuardEnabled",
"syncGuardMinimumFiles",
"networkWarningStyle",
"lessInformationInLog",
"showVerboseLog",
]);
});
it("keeps the three feature-level controls together under Extra menus", () => {
const group = createExtraMenuSettingSpecGroup();
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest";
vi.mock("@/common/translation", () => ({
$msg: (key: string) => key,
$t: (key: string) => key,
translateLiveSyncMessage: (key: string) => key,
}));
vi.mock("./PaneChangeLog.ts", () => ({ paneChangeLog: vi.fn() }));
@@ -20,6 +21,7 @@ vi.mock("./PaneMaintenance.ts", () => ({ paneMaintenance: vi.fn() }));
import {
createAdvancedSettingDefinitionGroups,
createGeneralSettingDefinitionGroups,
createSettingsPageCatalogue,
getSettingsRootGroupEntry,
type SettingsRootGroupId,
@@ -87,7 +89,6 @@ describe("settings page catalogue", () => {
"chunkSplitterVersion",
"customChunkSize",
"strictContentCheckOnStartup",
"pathNormalizationVersion",
"readChunksOnline",
"useOnlyLocalChunk",
"concurrencyOfReadChunksOnline",
@@ -97,4 +98,22 @@ describe("settings page catalogue", () => {
]);
expect(new Set(keys).size).toBe(keys.length);
});
it("registers path identity once in General settings and leaves it out of Advanced", () => {
const generalGroups = createGeneralSettingDefinitionGroups({
showEditorStatusDetails: () => true,
showVerboseLog: () => true,
});
const generalKeys = generalGroups.flatMap(({ items = [] }) =>
items.flatMap((item) => ("control" in item && item.control ? [item.control.key] : []))
);
const advancedKeys = createAdvancedSettingDefinitionGroups({ isCouchDB: () => true }).flatMap(
({ items = [] }) =>
items.flatMap((item) => ("control" in item && item.control ? [item.control.key] : []))
);
expect(generalGroups.map(({ heading }) => heading)).toContain("File identity");
expect(generalKeys.filter((key) => key === "pathNormalizationVersion")).toHaveLength(1);
expect(advancedKeys).not.toContain("pathNormalizationVersion");
});
});
+39 -12
View File
@@ -7,7 +7,7 @@ import {
REMOTE_COUCHDB,
REMOTE_MINIO,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { createNewVaultSettings } from "@vrtmrz/livesync-commonlib/settings";
import { createNewVaultSettings } from "@/common/newVaultSettings";
import { upsertRemoteConfigurationInPlace } from "@vrtmrz/livesync-commonlib/remote-configurations";
import { isObjectDifferent } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import Intro from "./SetupWizard/dialogs/Intro.svelte";
@@ -49,6 +49,8 @@ import {
type SetupInitialisationMode,
} from "@/serviceFeatures/setupObsidian/setupActivationLifecycle.ts";
import { isP2PMainRemote } from "@/common/remoteConfiguration.ts";
import { REMOTE_RESOURCE_KINDS } from "@vrtmrz/livesync-commonlib/replication";
import { resolveInitialPathIdentity } from "@/common/initialPathIdentity";
import type { P2PConnectionProbeAdmission } from "@vrtmrz/livesync-commonlib/p2p";
function copySettingsForRemoteProfileUpdate(settings: ObsidianLiveSyncSettings): ObsidianLiveSyncSettings {
@@ -58,11 +60,14 @@ function copySettingsForRemoteProfileUpdate(settings: ObsidianLiveSyncSettings):
};
}
function normaliseImportedIdDerivationSettings(settings: ObsidianLiveSyncSettings): ObsidianLiveSyncSettings {
function normaliseImportedIdentitySettings(settings: ObsidianLiveSyncSettings): ObsidianLiveSyncSettings {
// Setup URIs are complete imports even when their encoder omitted default-valued fields.
// Fill each missing half so a receiving device cannot supply the unrelated saved key.
return {
...settings,
pathNormalizationVersion: Object.prototype.hasOwnProperty.call(settings, "pathNormalizationVersion")
? settings.pathNormalizationVersion
: 0,
idDerivationVersion: Object.prototype.hasOwnProperty.call(settings, "idDerivationVersion")
? settings.idDerivationVersion
: 0,
@@ -233,7 +238,7 @@ export class SetupManager extends AbstractModule {
return false;
}
this._log("Setup URI dialog closed.", LOG_LEVEL_VERBOSE);
return await this.onConfirmApplySettingsFromWizard(normaliseImportedIdDerivationSettings(newSetting), userMode);
return await this.onConfirmApplySettingsFromWizard(normaliseImportedIdentitySettings(newSetting), userMode);
}
/**
@@ -271,6 +276,7 @@ export class SetupManager extends AbstractModule {
if (activate) {
newSetting.remoteType = REMOTE_COUCHDB;
}
if (!(await this.selectInitialPathIdentity(newSetting, userMode, activate))) return false;
upsertRemoteConfigurationInPlace(newSetting, "couchdb", { activate });
return await this.onConfirmApplySettingsFromWizard(newSetting, userMode, activate);
}
@@ -302,10 +308,37 @@ export class SetupManager extends AbstractModule {
if (activate) {
newSetting.remoteType = REMOTE_MINIO;
}
if (!(await this.selectInitialPathIdentity(newSetting, userMode, activate))) return false;
upsertRemoteConfigurationInPlace(newSetting, "s3", { activate });
return await this.onConfirmApplySettingsFromWizard(newSetting, userMode, activate);
}
private async selectInitialPathIdentity(
settings: ObsidianLiveSyncSettings,
userMode: UserMode,
activate: boolean
): Promise<boolean> {
if (!activate || (userMode !== UserMode.NewUser && userMode !== UserMode.ExistingUser)) return true;
try {
const probe = await this.services.replicator.createRemoteResource(
REMOTE_RESOURCE_KINDS.CONNECTION,
settings
);
settings.pathNormalizationVersion = await resolveInitialPathIdentity(
probe,
settings.pathNormalizationVersion
);
return true;
} catch (error) {
this._log(
"The remote path identity could not be inspected. Settings have not been applied.",
LOG_LEVEL_NOTICE
);
this._log(error, LOG_LEVEL_VERBOSE);
return false;
}
}
/**
* Handles manual setup for P2P
* @param userMode
@@ -345,10 +378,7 @@ export class SetupManager extends AbstractModule {
const e2eeConf = await this.dialogManager.openWithExplicitCancel<
SetupRemoteE2EEResultType,
SetupRemoteE2EEInitialData
>(
SetupRemoteE2EE,
{ settings: currentSetting, newVault: userMode === UserMode.NewUser }
);
>(SetupRemoteE2EE, { settings: currentSetting, newVault: userMode === UserMode.NewUser });
if (e2eeConf === "cancelled") {
this._log("E2EE configuration cancelled.", LOG_LEVEL_NOTICE);
return false;
@@ -397,10 +427,7 @@ export class SetupManager extends AbstractModule {
const e2eeConf = await this.dialogManager.openWithExplicitCancel<
SetupRemoteE2EEResultType,
SetupRemoteE2EEInitialData
>(
SetupRemoteE2EE,
{ settings: originalSetting, newVault: userMode === UserMode.NewUser }
);
>(SetupRemoteE2EE, { settings: originalSetting, newVault: userMode === UserMode.NewUser });
if (e2eeConf === "cancelled") {
this._log("Manual configuration cancelled.", LOG_LEVEL_NOTICE);
return await this.onOnboard(userMode);
@@ -545,7 +572,7 @@ export class SetupManager extends AbstractModule {
async decodeQR(qr: string) {
let newSettings: ObsidianLiveSyncSettings;
try {
newSettings = normaliseImportedIdDerivationSettings(decodeSettingsFromQRCodeData(qr));
newSettings = normaliseImportedIdentitySettings(decodeSettingsFromQRCodeData(qr));
} catch {
this._log("The QR configuration could not be decoded or contains unsupported settings.", LOG_LEVEL_NOTICE);
return false;
+143 -37
View File
@@ -3,12 +3,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import {
DEFAULT_SETTINGS,
REMOTE_COUCHDB,
REMOTE_MINIO,
REMOTE_P2P,
type ObsidianLiveSyncSettings,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { SettingService } from "@vrtmrz/livesync-commonlib/compat/services/base/SettingService";
import { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
import { createNewVaultSettings } from "@vrtmrz/livesync-commonlib/settings";
import { createNewVaultSettings } from "@/common/newVaultSettings";
import type {
P2PConnectionProbeAdmission,
P2PConnectionProbeAdmissionResult,
@@ -98,6 +99,12 @@ function createSetupManager() {
dialogManager,
},
setting,
replicator: {
createRemoteResource: vi.fn(async () => ({
inspectPathIdentity: vi.fn(async () => ({ status: "configured", pathNormalizationVersion: 0 })),
dispose: vi.fn(async () => undefined),
})),
},
} as any;
const core: any = {
_services: services,
@@ -165,6 +172,99 @@ describe("SetupManager", () => {
);
});
it.each([REMOTE_COUCHDB, REMOTE_MINIO])(
"uses an existing remote declaration during first manual setup for %s",
async (remoteType) => {
const { manager, core, dialogManager } = createSetupManager();
const dispose = vi.fn(async () => undefined);
core.services.replicator.createRemoteResource.mockResolvedValue({
inspectPathIdentity: vi.fn(async () => ({ status: "configured", pathNormalizationVersion: 0 })),
dispose,
});
const confirm = vi.spyOn(manager, "onConfirmApplySettingsFromWizard").mockResolvedValue(true);
dialogManager.openWithExplicitCancel.mockResolvedValue({
couchDB_URI: "https://example.test",
couchDB_DBNAME: "vault",
couchDB_USER: "user",
couchDB_PASSWORD: "pass",
endpoint: "https://objects.test",
bucket: "vault",
accessKey: "key",
secretKey: "secret",
region: "auto",
bucketPrefix: "",
});
const initial = { ...createLegacyRemoteSetting(), pathNormalizationVersion: 1 } as ObsidianLiveSyncSettings;
if (remoteType === REMOTE_COUCHDB) await manager.onCouchDBManualSetup(UserMode.NewUser, initial);
else await manager.onBucketManualSetup(UserMode.NewUser, initial);
expect(confirm).toHaveBeenCalledWith(
expect.objectContaining({ pathNormalizationVersion: 0 }),
UserMode.NewUser,
true
);
expect(dispose).toHaveBeenCalledOnce();
expect(initial.pathNormalizationVersion).toBe(1);
}
);
it.each([
[{ status: "empty" }, 1],
[{ status: "configured", pathNormalizationVersion: 1 }, 1],
] as const)("uses inspected setup identity %s", async (inspection, expected) => {
const { manager, core, dialogManager } = createSetupManager();
core.services.replicator.createRemoteResource.mockResolvedValue({
inspectPathIdentity: vi.fn(async () => inspection),
dispose: vi.fn(async () => undefined),
});
const confirm = vi.spyOn(manager, "onConfirmApplySettingsFromWizard").mockResolvedValue(true);
dialogManager.openWithExplicitCancel.mockResolvedValue({
couchDB_URI: "https://example.test",
couchDB_DBNAME: "vault",
});
await manager.onCouchDBManualSetup(UserMode.NewUser, createNewVaultSettings());
expect(confirm).toHaveBeenCalledWith(
expect.objectContaining({ pathNormalizationVersion: expected }),
UserMode.NewUser,
true
);
});
it("does not apply setup settings when identity inspection fails", async () => {
const { manager, core, dialogManager, setting } = createSetupManager();
const failure = new Error("remote unavailable");
const dispose = vi.fn(async () => undefined);
core.services.replicator.createRemoteResource.mockResolvedValue({
inspectPathIdentity: vi.fn(async () => {
throw failure;
}),
dispose,
});
const confirm = vi.spyOn(manager, "onConfirmApplySettingsFromWizard").mockResolvedValue(true);
dialogManager.openWithExplicitCancel.mockResolvedValue({
couchDB_URI: "https://example.test",
couchDB_DBNAME: "vault",
});
await expect(manager.onCouchDBManualSetup(UserMode.NewUser, createNewVaultSettings())).resolves.toBe(false);
expect(confirm).not.toHaveBeenCalled();
expect(setting.saveSettingData).not.toHaveBeenCalled();
expect(dispose).toHaveBeenCalledOnce();
});
it.each([
[UserMode.Update, true],
[UserMode.NewUser, false],
] as const)("keeps identity during profile edits in mode %s with activation %s", async (mode, activate) => {
const { manager, core, dialogManager } = createSetupManager();
const confirm = vi.spyOn(manager, "onConfirmApplySettingsFromWizard").mockResolvedValue(true);
dialogManager.openWithExplicitCancel.mockResolvedValue({
couchDB_URI: "https://example.test",
couchDB_DBNAME: "vault",
});
await manager.onCouchDBManualSetup(mode, createLegacyRemoteSetting(), activate);
expect(core.services.replicator.createRemoteResource).not.toHaveBeenCalled();
expect(confirm).toHaveBeenCalledWith(expect.objectContaining({ pathNormalizationVersion: 0 }), mode, activate);
});
it("starts manual new-user setup from the recommended new-Vault settings", async () => {
const { manager, dialogManager } = createSetupManager();
dialogManager.openWithExplicitCancel.mockResolvedValueOnce("configure-manually");
@@ -192,12 +292,13 @@ describe("SetupManager", () => {
expect(setting.currentSettings().activeConfigurationId).toBe("legacy-couchdb");
});
it("compatibility: treats omitted ID derivation fields in a Setup URI as legacy defaults", async () => {
it("compatibility: treats omitted identity fields in a Setup URI as legacy defaults", async () => {
const { manager, setting, dialogManager } = createSetupManager();
const savedKey = "12".repeat(32);
setting.settings = {
...createLegacyRemoteSetting(),
isConfigured: true,
pathNormalizationVersion: 1,
idDerivationVersion: 1,
idDerivationKey: savedKey,
};
@@ -205,6 +306,7 @@ describe("SetupManager", () => {
...createLegacyRemoteSetting(),
isConfigured: true,
} as Partial<ObsidianLiveSyncSettings>;
delete imported.pathNormalizationVersion;
delete imported.idDerivationVersion;
delete imported.idDerivationKey;
vi.spyOn(setting, "adjustSettings").mockImplementation((settings) => Promise.resolve(settings));
@@ -213,6 +315,7 @@ describe("SetupManager", () => {
await manager.onUseSetupURI(UserMode.Unknown, "mock-config://legacy-settings");
const mergedSettings = vi.mocked(setting.adjustSettings).mock.calls[0][0];
expect(mergedSettings.pathNormalizationVersion).toBe(0);
expect(mergedSettings.idDerivationVersion).toBe(0);
expect(mergedSettings.idDerivationKey).toBe("");
expect(setting.currentSettings().idDerivationKey).toBe(savedKey);
@@ -489,44 +592,47 @@ describe("SetupManager", () => {
it.each([
{ importedSize: 0, expectedSize: 0 },
{ importedSize: undefined, expectedSize: 60 },
])("preserves modern Setup URI profiles and adopts the supplied chunk size $importedSize", async ({ importedSize, expectedSize }) => {
const { manager, setting, dialogManager } = createSetupManager();
setting.settings = {
...setting.currentSettings(),
customChunkSize: 60,
};
const imported = {
...DEFAULT_SETTINGS,
remoteConfigurations: {
couch: {
id: "couch",
name: "Office CouchDB",
uri: "sls+https://alice:secret@couch.example/?db=notes",
isEncrypted: false,
])(
"preserves modern Setup URI profiles and adopts the supplied chunk size $importedSize",
async ({ importedSize, expectedSize }) => {
const { manager, setting, dialogManager } = createSetupManager();
setting.settings = {
...setting.currentSettings(),
customChunkSize: 60,
};
const imported = {
...DEFAULT_SETTINGS,
remoteConfigurations: {
couch: {
id: "couch",
name: "Office CouchDB",
uri: "sls+https://alice:secret@couch.example/?db=notes",
isEncrypted: false,
},
archive: {
id: "archive",
name: "Archive bucket",
uri: "sls+s3://key:secret@storage.example/?endpoint=https%3A%2F%2Fstorage.example&bucket=archive&region=auto",
isEncrypted: false,
},
},
archive: {
id: "archive",
name: "Archive bucket",
uri: "sls+s3://key:secret@storage.example/?endpoint=https%3A%2F%2Fstorage.example&bucket=archive&region=auto",
isEncrypted: false,
},
},
activeConfigurationId: "archive",
} as ObsidianLiveSyncSettings;
if (importedSize === undefined) delete (imported as Partial<ObsidianLiveSyncSettings>).customChunkSize;
else imported.customChunkSize = importedSize;
dialogManager.openWithExplicitCancel
.mockResolvedValueOnce(imported)
.mockResolvedValueOnce("compatible-existing-user");
activeConfigurationId: "archive",
} as ObsidianLiveSyncSettings;
if (importedSize === undefined) delete (imported as Partial<ObsidianLiveSyncSettings>).customChunkSize;
else imported.customChunkSize = importedSize;
dialogManager.openWithExplicitCancel
.mockResolvedValueOnce(imported)
.mockResolvedValueOnce("compatible-existing-user");
await manager.onUseSetupURI(UserMode.Unknown, "mock-config://modern-settings");
await manager.onUseSetupURI(UserMode.Unknown, "mock-config://modern-settings");
const current = setting.currentSettings();
expect(current.customChunkSize).toBe(expectedSize);
expect(current.remoteConfigurations).toEqual(imported.remoteConfigurations);
expect(current.activeConfigurationId).toBe("archive");
expect(Object.keys(current.remoteConfigurations).some((id) => id.startsWith("legacy-"))).toBe(false);
});
const current = setting.currentSettings();
expect(current.customChunkSize).toBe(expectedSize);
expect(current.remoteConfigurations).toEqual(imported.remoteConfigurations);
expect(current.activeConfigurationId).toBe("archive");
expect(Object.keys(current.remoteConfigurations).some((id) => id.startsWith("legacy-"))).toBe(false);
}
);
it("adds and activates a manually configured CouchDB without replacing existing profiles", async () => {
const { manager, setting, dialogManager } = createSetupManager();
@@ -17,6 +17,7 @@ export interface ConflictCheckingDependencies {
IConflictService,
"getOptionalConflictCheckMethod" | "queueCheckFor" | "resolve" | "resolveByNewest"
>;
readonly signal: AbortSignal;
readonly conflictProcessQueueCount: ReactiveSource<number>;
readonly cancelActiveResolution: (filename: FilePathWithPrefix) => void;
readonly currentSettings: () => ConflictCheckingSettings;
@@ -27,6 +28,7 @@ export interface ConflictCheckingDependencies {
export interface ConflictCheckingHandlers {
readonly queueCheckForIfOpen: (file: FilePathWithPrefix) => Promise<void>;
readonly queueCheckFor: (file: FilePathWithPrefix) => Promise<void>;
/** Wait for active checks; false means the queue was cancelled during shutdown. */
readonly ensureAllProcessed: () => Promise<boolean>;
}
@@ -45,11 +47,13 @@ export function createConflictCheckingHandlers(dependencies: ConflictCheckingDep
// and automatic merges can use the remaining execution slots.
concurrency: 10,
onProgress(remaining) {
// Accepted checks still update their accounting as they settle.
dependencies.conflictProcessQueueCount.value = remaining;
},
});
const queueCheckForIfOpen = async (file: FilePathWithPrefix): Promise<void> => {
if (dependencies.signal.aborted) return;
const path = file;
if (dependencies.currentSettings().checkConflictOnlyOnOpen) {
const activeFile: FilePath | undefined = dependencies.vault.getActiveFilePath();
@@ -62,7 +66,9 @@ export function createConflictCheckingHandlers(dependencies: ConflictCheckingDep
};
const queueCheckFor = async (file: FilePathWithPrefix): Promise<void> => {
if (dependencies.signal.aborted) return;
const optionalConflictResult = await dependencies.conflict.getOptionalConflictCheckMethod(file);
if (dependencies.signal.aborted) return;
if (optionalConflictResult == true) {
// The conflict has been resolved by another process.
return;
@@ -79,6 +85,16 @@ export function createConflictCheckingHandlers(dependencies: ConflictCheckingDep
};
const ensureAllProcessed = (): Promise<boolean> => conflictQueue.waitForIdle();
const abortQueue = (): void => {
// Shutdown keeps its existing database-close order. Observe cleanup
// without making the lifecycle wait for an active resolver.
void conflictQueue.abort().catch((error) => dependencies.log(error, LOG_LEVEL_VERBOSE));
};
if (dependencies.signal.aborted) {
abortQueue();
} else {
dependencies.signal.addEventListener("abort", abortQueue, { once: true });
}
return {
queueCheckForIfOpen,
@@ -14,7 +14,7 @@ import {
type MetaEntry,
type ObsidianLiveSyncSettings,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { EVENT_CONFLICT_CANCELLED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
import { EVENT_CONFLICT_CANCELLED, EVENT_PLUGIN_UNLOADED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
import { NO_INTERACTION } from "@vrtmrz/livesync-commonlib/replication";
import type { IAPIService } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
import {
@@ -44,6 +44,7 @@ type HarnessOptions = {
function createHarness(options: HarnessOptions = {}) {
const context = createServiceContext();
const conflict = new InjectableConflictService(context);
const unloadHandlers: Array<() => Promise<boolean>> = [];
const settings = { ...DEFAULT_SETTINGS, ...options.settings };
const tryAutoMerge = vi.fn();
const databaseFileAccess = {
@@ -62,7 +63,14 @@ function createHarness(options: HarnessOptions = {}) {
const activeFile = vi.fn(() => options.activeFile);
const services = {
API: { addLog },
appLifecycle: { isSuspended: vi.fn(() => false) },
appLifecycle: {
isSuspended: vi.fn(() => false),
onUnload: {
addHandler: vi.fn((handler: () => Promise<boolean>) => {
unloadHandlers.push(handler);
}),
},
},
conflict,
context,
database: { localDatabase: { tryAutoMerge } },
@@ -88,9 +96,22 @@ function createHarness(options: HarnessOptions = {}) {
serviceModules,
storageAccess,
tryAutoMerge,
unload: async () => {
for (const handler of unloadHandlers) await handler();
},
};
}
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
}
function leaf(rev: string, data: string, mtime: number): ConflictLeaf {
return { rev, data, mtime, ctime: mtime, deleted: false };
}
@@ -919,6 +940,347 @@ describe("conflict resolution serviceFeature", () => {
expect(harness.conflict.conflictProcessQueueCount.value).toBe(0);
});
it("reports queue cancellation and logs a resolver rejection after unload", async () => {
const path = "queued-rejection-after-unload.md" as FilePathWithPrefix;
const harness = createHarness();
const started = deferred<void>();
const pendingResolve = deferred<void>();
const resolve = vi.fn(async () => {
started.resolve(undefined);
await pendingResolve.promise;
});
harness.conflict.resolve.setHandler(resolve, true);
await harness.conflict.queueCheckFor(path);
await started.promise;
const completion = harness.conflict.ensureAllProcessed().then(
(result) => ({ status: "resolved" as const, result }),
(error: unknown) => ({ status: "rejected" as const, error })
);
await harness.unload();
await harness.unload();
const failure = new Error("Resolver rejected after unload");
pendingResolve.reject(failure);
await expect(completion).resolves.toEqual({ status: "resolved", result: false });
expect(resolve).toHaveBeenCalledOnce();
expect(harness.addLog).toHaveBeenCalledWith(failure, LOG_LEVEL_VERBOSE, "");
});
it("allows an active inspection to complete normally after unload", async () => {
const path = "not-conflicted-after-unload.md" as FilePathWithPrefix;
const harness = createHarness();
const queueCheckFor = vi.spyOn(harness.conflict, "queueCheckFor");
const started = deferred<void>();
const pendingMergeCheck = deferred<{ ok: typeof NOT_CONFLICTED }>();
harness.tryAutoMerge.mockImplementation(async () => {
started.resolve(undefined);
return await pendingMergeCheck.promise;
});
const emitEvent = vi.spyOn(harness.context.events, "emitEvent");
const operation = harness.conflict.resolve(path);
const completion = operation.then(
() => ({ status: "resolved" as const }),
(error: unknown) => ({ status: "rejected" as const, error })
);
await started.promise;
expect(harness.tryAutoMerge).toHaveBeenCalledWith(path, true);
harness.context.events.emitEvent(EVENT_PLUGIN_UNLOADED);
pendingMergeCheck.resolve({ ok: NOT_CONFLICTED });
await expect(completion).resolves.toEqual({ status: "resolved" });
expect(emitEvent).toHaveBeenCalledWith(EVENT_CONFLICT_CANCELLED, path);
expect(queueCheckFor).not.toHaveBeenCalled();
expect(harness.addLog).toHaveBeenCalled();
});
it("continues an accepted auto-merge when storage completes after unload", async () => {
const path = "stored-merge-after-unload.md" as FilePathWithPrefix;
const harness = createHarness({ settings: { syncAfterMerge: false } });
const queueCheckFor = vi.spyOn(harness.conflict, "queueCheckFor");
const started = deferred<void>();
const pendingStore = deferred<boolean>();
harness.tryAutoMerge.mockResolvedValue({ result: "Merged content\n", conflictedRev: "2-conflict" });
harness.databaseFileAccess.storeContent.mockImplementation(async () => {
started.resolve(undefined);
return await pendingStore.promise;
});
const emitEvent = vi.spyOn(harness.context.events, "emitEvent");
const operation = harness.conflict.resolve(path);
const completion = operation.then(
() => ({ status: "resolved" as const }),
(error: unknown) => ({ status: "rejected" as const, error })
);
await started.promise;
expect(harness.databaseFileAccess.storeContent).toHaveBeenCalledWith(path, "Merged content\n");
await harness.unload();
pendingStore.resolve(true);
await expect(completion).resolves.toEqual({ status: "resolved" });
expect(harness.fileHandler.deleteRevisionFromDB).toHaveBeenCalledWith(path, "2-conflict");
expect(harness.fileHandler.dbToStorage).toHaveBeenCalled();
expect(queueCheckFor).toHaveBeenCalledWith(path);
expect(emitEvent).toHaveBeenCalledWith(EVENT_CONFLICT_CANCELLED, path);
expect(harness.addLog).toHaveBeenCalled();
await expect(harness.conflict.ensureAllProcessed()).resolves.toBe(false);
});
it("reflects a revision deletion which succeeds after the unload event", async () => {
const path = "deleted-after-unload.md" as FilePathWithPrefix;
const harness = createHarness();
const started = deferred<void>();
const pendingDelete = deferred<boolean>();
harness.fileHandler.deleteRevisionFromDB.mockImplementation(async () => {
started.resolve(undefined);
return await pendingDelete.promise;
});
const emitEvent = vi.spyOn(harness.context.events, "emitEvent");
const operation = harness.conflict.resolveByDeletingRevision(path, "2-conflict", "UI Selected");
const completion = operation.then(
(result) => ({ status: "resolved" as const, result }),
(error: unknown) => ({ status: "rejected" as const, error })
);
await started.promise;
expect(harness.fileHandler.deleteRevisionFromDB).toHaveBeenCalledWith(path, "2-conflict");
harness.context.events.emitEvent(EVENT_PLUGIN_UNLOADED);
pendingDelete.resolve(true);
await expect(completion).resolves.toEqual({ status: "resolved", result: AUTO_MERGED });
expect(harness.databaseFileAccess.getConflictedRevs).toHaveBeenCalledWith(path);
expect(harness.fileHandler.dbToStorage).toHaveBeenCalledWith(path, path, true);
expect(emitEvent).toHaveBeenCalledWith(EVENT_CONFLICT_CANCELLED, path);
expect(harness.addLog).toHaveBeenCalled();
});
it("lets active replication finish while rejecting its next conflict check", async () => {
const path = "replication-after-unload.md" as FilePathWithPrefix;
const harness = createHarness({ settings: { syncAfterMerge: true } });
const queueCheckFor = vi.spyOn(harness.conflict, "queueCheckFor");
const started = deferred<void>();
const pendingReplication = deferred<{ status: "completed" }>();
harness.tryAutoMerge.mockResolvedValue({ ok: AUTO_MERGED });
harness.services.replication.replicateUnattendedByEvent.mockImplementation(async () => {
started.resolve(undefined);
return await pendingReplication.promise;
});
const operation = harness.conflict.resolve(path);
const completion = operation.then(
() => ({ status: "resolved" as const }),
(error: unknown) => ({ status: "rejected" as const, error })
);
await started.promise;
expect(harness.services.replication.replicateUnattendedByEvent).toHaveBeenCalledWith({
trigger: "merge",
interaction: NO_INTERACTION,
});
await harness.unload();
pendingReplication.resolve({ status: "completed" });
await expect(completion).resolves.toEqual({ status: "resolved" });
expect(queueCheckFor).toHaveBeenCalledWith(path);
expect(harness.tryAutoMerge).toHaveBeenCalledOnce();
await expect(harness.conflict.ensureAllProcessed()).resolves.toBe(false);
expect(harness.addLog).toHaveBeenCalled();
});
it("does not start resolveByNewest when an optional conflict check returns newer after unload", async () => {
const path = "optional-newer-after-unload.md" as FilePathWithPrefix;
const harness = createHarness();
const started = deferred<void>();
const pendingOptionalCheck = deferred<"newer" | undefined>();
const optionalCheck = vi.fn(async () => {
started.resolve(undefined);
return await pendingOptionalCheck.promise;
});
const resolveByNewest = vi.fn(async () => true);
const unregisterOptionalCheck = harness.conflict.getOptionalConflictCheckMethod.addHandler(optionalCheck);
harness.conflict.resolveByNewest.setHandler(resolveByNewest, true);
const operation = harness.conflict.queueCheckFor(path);
const completion = operation.then(
() => ({ status: "resolved" as const }),
(error: unknown) => ({ status: "rejected" as const, error })
);
try {
await started.promise;
expect(optionalCheck).toHaveBeenCalledWith(path);
await harness.unload();
harness.addLog.mockImplementation(() => {
throw new Error("Handler addLog not assigned");
});
pendingOptionalCheck.resolve("newer");
await expect(completion).resolves.toEqual({ status: "resolved" });
expect(resolveByNewest).not.toHaveBeenCalled();
expect(harness.addLog).not.toHaveBeenCalled();
} finally {
unregisterOptionalCheck();
}
});
it.each(["lifecycle", "event"] as const)(
"stops admission and preserves ordinary database failures after %s disposal",
async (trigger) => {
const path = "operation-after-unload.md" as FilePathWithPrefix;
const harness = createHarness();
const optionalCheck = vi.fn(async () => "newer" as const);
const unregisterOptionalCheck = harness.conflict.getOptionalConflictCheckMethod.addHandler(optionalCheck);
const publicOperations = [
vi.spyOn(harness.conflict, "queueCheckForIfOpen"),
vi.spyOn(harness.conflict, "queueCheckFor"),
vi.spyOn(harness.conflict, "resolve"),
vi.spyOn(harness.conflict, "resolveByDeletingRevision"),
vi.spyOn(harness.conflict, "resolveByNewest"),
vi.spyOn(harness.conflict, "resolveAllConflictedFilesByNewerOnes"),
];
const emitEvent = vi.spyOn(harness.context.events, "emitEvent");
if (trigger === "lifecycle") {
await harness.unload();
} else {
harness.context.events.emitEvent(EVENT_PLUGIN_UNLOADED);
}
const failure = new Error("database closed");
harness.tryAutoMerge.mockRejectedValue(failure);
harness.databaseFileAccess.fetchEntryMeta.mockResolvedValue(false);
harness.fileHandler.deleteRevisionFromDB.mockResolvedValue(false);
try {
const results = await Promise.allSettled([
harness.conflict.queueCheckForIfOpen(path),
harness.conflict.queueCheckFor(path),
harness.conflict.resolve(path),
harness.conflict.resolveByDeletingRevision(path, "2-conflict", "UI Selected"),
harness.conflict.resolveByNewest(path),
harness.conflict.resolveAllConflictedFilesByNewerOnes(),
]);
expect(results).toEqual([
{ status: "fulfilled", value: undefined },
{ status: "fulfilled", value: undefined },
{ status: "rejected", reason: failure },
{ status: "fulfilled", value: MISSING_OR_ERROR },
{ status: "fulfilled", value: false },
{ status: "fulfilled", value: undefined },
]);
await expect(harness.conflict.ensureAllProcessed()).resolves.toBe(false);
for (const operation of publicOperations) expect(operation).toHaveBeenCalledOnce();
expect(optionalCheck).not.toHaveBeenCalled();
expect(harness.tryAutoMerge).toHaveBeenCalledOnce();
expect(harness.databaseFileAccess.fetchEntryMeta).toHaveBeenCalledOnce();
expect(harness.databaseFileAccess.getConflictedRevs).not.toHaveBeenCalled();
expect(harness.databaseFileAccess.storeContent).not.toHaveBeenCalled();
expect(harness.fileHandler.deleteRevisionFromDB).toHaveBeenCalledOnce();
expect(harness.fileHandler.dbToStorage).not.toHaveBeenCalled();
expect(harness.storageAccess.getFileNames).toHaveBeenCalledOnce();
expect(harness.services.replication.replicateUnattendedByEvent).not.toHaveBeenCalled();
expect(emitEvent).not.toHaveBeenCalledWith(EVENT_CONFLICT_CANCELLED, path);
expect(harness.addLog).toHaveBeenCalled();
} finally {
unregisterOptionalCheck();
}
}
);
it("cancels unstarted checks after active checks settle during unload", async () => {
const paths = Array.from({ length: 11 }, (_, index) => `unload-concurrent-${index}.md` as FilePathWithPrefix);
const harness = createHarness();
const tenStarted = deferred<void>();
const releaseResolvers = deferred<void>();
const resolve = vi.fn(async (_path: FilePathWithPrefix) => {
if (resolve.mock.calls.length === 10) tenStarted.resolve(undefined);
await releaseResolvers.promise;
});
harness.conflict.resolve.setHandler(resolve, true);
await Promise.all(paths.map(async (path) => await harness.conflict.queueCheckFor(path)));
await tenStarted.promise;
expect(harness.conflict.conflictProcessQueueCount.value).toBe(11);
const completion = harness.conflict.ensureAllProcessed();
harness.context.events.emitEvent(EVENT_PLUGIN_UNLOADED);
releaseResolvers.resolve(undefined);
await expect(completion).resolves.toBe(false);
expect(resolve).toHaveBeenCalledTimes(10);
expect(harness.conflict.conflictProcessQueueCount.value).toBe(1);
});
it("reports an unavailable database when an active bulk scan completes after unload", async () => {
const path = "bulk-resolution-after-unload.md" as FilePathWithPrefix;
const harness = createHarness();
const namesStarted = deferred<void>();
const pendingNames = deferred<FilePathWithPrefix[]>();
harness.storageAccess.getFileNames.mockImplementation(async () => {
namesStarted.resolve(undefined);
return await pendingNames.promise;
});
const operation = harness.conflict.resolveAllConflictedFilesByNewerOnes();
await namesStarted.promise;
expect(harness.addLog).toHaveBeenCalled();
harness.addLog.mockClear();
await harness.unload();
harness.databaseFileAccess.fetchEntryMeta.mockResolvedValue(false);
pendingNames.resolve([path]);
await expect(operation).resolves.toBeUndefined();
expect(harness.databaseFileAccess.fetchEntryMeta).toHaveBeenCalledWith(path, undefined, true);
expect(harness.databaseFileAccess.getConflictedRevs).not.toHaveBeenCalled();
expect(harness.fileHandler.deleteRevisionFromDB).not.toHaveBeenCalled();
expect(harness.fileHandler.dbToStorage).not.toHaveBeenCalled();
expect(
harness.addLog.mock.calls.some(([message]) => String(message).includes("Could not get current revision"))
).toBe(true);
});
it("keeps a replacement queue independent of repeated shutdown of the previous queue", async () => {
const first = createHarness();
const second = createHarness();
first.tryAutoMerge.mockResolvedValue({ ok: NOT_CONFLICTED });
second.tryAutoMerge.mockResolvedValue({ ok: NOT_CONFLICTED });
first.context.events.emitEvent(EVENT_PLUGIN_UNLOADED);
await first.unload();
await first.unload();
await first.conflict.queueCheckFor("retired.md" as FilePathWithPrefix);
await second.conflict.queueCheckFor("active.md" as FilePathWithPrefix);
await expect(first.conflict.ensureAllProcessed()).resolves.toBe(false);
await expect(second.conflict.ensureAllProcessed()).resolves.toBe(true);
expect(first.tryAutoMerge).not.toHaveBeenCalled();
expect(second.tryAutoMerge).toHaveBeenCalledExactlyOnceWith("active.md", true);
});
it("reports a storage failure after shutdown without treating the queue as completed", async () => {
const harness = createHarness();
const started = deferred<void>();
const pendingStore = deferred<boolean>();
const failure = new Error("database is closed");
harness.tryAutoMerge.mockResolvedValue({ result: "Merged content", conflictedRev: "2-conflict" });
harness.databaseFileAccess.storeContent.mockImplementation(() => {
started.resolve(undefined);
return pendingStore.promise;
});
await harness.conflict.queueCheckFor("closed-database.md" as FilePathWithPrefix);
await started.promise;
const completion = harness.conflict.ensureAllProcessed();
await harness.unload();
pendingStore.reject(failure);
await expect(completion).resolves.toBe(false);
expect(harness.fileHandler.deleteRevisionFromDB).not.toHaveBeenCalled();
expect(harness.fileHandler.dbToStorage).not.toHaveBeenCalled();
expect(harness.addLog).toHaveBeenCalledWith(failure, LOG_LEVEL_VERBOSE, "");
});
it("drains requeued automatic checks when every resolver slot is occupied", async () => {
const harness = createHarness({ settings: { syncAfterMerge: false } });
const paths = Array.from({ length: 10 }, (_, index) => `requeue-${index}.md` as FilePathWithPrefix);
@@ -0,0 +1,89 @@
import { describe, expect, it, vi } from "vitest";
import PouchDB from "pouchdb-core";
import MemoryAdapter from "pouchdb-adapter-memory";
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
import { InjectableConflictService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableConflictService";
import { LOG_LEVEL_VERBOSE, type FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { createConflictCheckingHandlers } from "./checker";
PouchDB.plugin(MemoryAdapter);
type Probe = { completed: number };
function deferred() {
let resolve!: () => void;
const promise = new Promise<void>((finish) => {
resolve = finish;
});
return { promise, resolve };
}
describe("conflict-check shutdown with a real PouchDB handle", () => {
it.each([false, true])(
"reports cancellation and preserves the database outcome when closed before completion: %s",
async (closeDuringShutdown) => {
const name = `conflict-shutdown-${crypto.randomUUID()}`;
const db = new PouchDB<Probe>(name, { adapter: "memory" });
const started = deferred();
const release = deferred();
const conflict = new InjectableConflictService(createServiceContext());
const lifetime = new AbortController();
const log = vi.fn();
const resolver = vi.fn(async () => {
started.resolve();
await release.promise;
const probe = await db.get("_local/shutdown-probe");
await db.put({ ...probe, completed: probe.completed + 1 });
});
conflict.resolve.setHandler(resolver);
const checking = createConflictCheckingHandlers({
conflict,
signal: lifetime.signal,
conflictProcessQueueCount: conflict.conflictProcessQueueCount,
cancelActiveResolution: vi.fn(),
currentSettings: () => ({ checkConflictOnlyOnOpen: false }),
vault: { getActiveFilePath: () => undefined },
log,
});
conflict.queueCheckFor.setHandler(checking.queueCheckFor);
let closed = false;
let reopened: PouchDB.Database<Probe> | undefined;
let completion: Promise<boolean> | undefined;
try {
await db.put({ _id: "_local/shutdown-probe", completed: 0 });
await checking.queueCheckFor("active.md" as FilePathWithPrefix);
await started.promise;
completion = checking.ensureAllProcessed();
lifetime.abort();
lifetime.abort();
await checking.queueCheckFor("late.md" as FilePathWithPrefix);
if (closeDuringShutdown) {
await db.close();
closed = true;
}
release.resolve();
await expect(completion).resolves.toBe(false);
expect(resolver).toHaveBeenCalledOnce();
if (closeDuringShutdown) {
expect(log).toHaveBeenCalledWith(
expect.objectContaining({ message: expect.stringMatching(/closed/i) }),
LOG_LEVEL_VERBOSE
);
} else {
expect(log).not.toHaveBeenCalled();
await db.close();
closed = true;
}
reopened = new PouchDB<Probe>(name, { adapter: "memory" });
const persisted = await reopened.get("_local/shutdown-probe");
expect(persisted.completed).toBe(closeDuringShutdown ? 0 : 1);
} finally {
release.resolve();
await completion?.catch(() => undefined);
if (!closed) await db.close();
await (reopened ?? new PouchDB<Probe>(name, { adapter: "memory" })).destroy();
}
}
);
});
@@ -6,6 +6,7 @@ import type { LogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib
import type { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub";
import { createConflictCheckingHandlers } from "./checker";
import { createConflictResolutionOperations } from "./operations";
import { EVENT_PLUGIN_UNLOADED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
export type ConflictResolutionHost = NecessaryServices<
"appLifecycle" | "conflict" | "database" | "replication" | "setting" | "vault",
@@ -18,6 +19,7 @@ export type ConflictResolutionHost = NecessaryServices<
/** Compose the host-neutral conflict handlers with the host's borrowed logger. */
export const useConflictResolutionFeature = createServiceFeature((host: ConflictResolutionHost): void => {
const { services, serviceModules, log } = host;
const lifetime = new AbortController();
const operations = createConflictResolutionOperations({
events: services.context.events,
databaseFileAccess: serviceModules.databaseFileAccess,
@@ -38,6 +40,7 @@ export const useConflictResolutionFeature = createServiceFeature((host: Conflict
currentSettings: () => services.setting.currentSettings(),
vault: services.vault,
log,
signal: lifetime.signal,
});
services.conflict.queueCheckForIfOpen.setHandler(checking.queueCheckForIfOpen);
@@ -47,6 +50,13 @@ export const useConflictResolutionFeature = createServiceFeature((host: Conflict
services.conflict.resolve.setHandler(operations.resolve);
services.conflict.resolveByNewest.setHandler(operations.resolveByNewest);
services.conflict.resolveAllConflictedFilesByNewerOnes.setHandler(operations.resolveAllConflictedFilesByNewerOnes);
const stop = (): void => lifetime.abort();
services.context.events.onceEvent(EVENT_PLUGIN_UNLOADED, stop, { signal: lifetime.signal });
services.appLifecycle.onUnload.addHandler(() => {
stop();
return Promise.resolve(true);
});
});
export { createConflictCheckingHandlers } from "./checker";
@@ -27,9 +27,10 @@ import type { LiveSyncEventHub } from "@vrtmrz/livesync-commonlib/context";
import type { StorageAccess } from "@vrtmrz/livesync-commonlib/compat/interfaces/StorageAccess";
import { EVENT_CONFLICT_CANCELLED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
import { NO_INTERACTION } from "@vrtmrz/livesync-commonlib/replication";
import { parseDocumentPath, splitDocumentPath } from "@vrtmrz/livesync-commonlib/storage";
import { isLockAcquired, withLock } from "octagonal-wheels/concurrency/lock";
import diff_match_patch from "diff-match-patch";
import { stripAllPrefixes, isPlainText } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
import { isPlainText } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
export type ConflictResolutionSettings = Pick<
ObsidianLiveSyncSettings,
@@ -67,6 +68,7 @@ export interface ConflictResolutionOperations {
readonly resolveAllConflictedFilesByNewerOnes: () => Promise<void>;
}
/** Create conflict-resolution operations which retain their normal completion and failure paths. */
export function createConflictResolutionOperations(
dependencies: ConflictResolutionOperationsDependencies
): ConflictResolutionOperations {
@@ -108,7 +110,9 @@ export function createConflictResolutionOperations(
return AUTO_MERGED;
}
// If no conflicts were found, write the resolved content to the storage.
if (!(await dependencies.fileHandler.dbToStorage(path, stripAllPrefixes(path), true))) {
const documentPath = parseDocumentPath(path);
const { path: filePath } = splitDocumentPath(documentPath);
if (!(await dependencies.fileHandler.dbToStorage(documentPath, filePath, true))) {
dependencies.log(`Could not write the resolved content to the storage: ${path}`, LOG_LEVEL_NOTICE);
return MISSING_OR_ERROR;
}
@@ -2,7 +2,7 @@ import type { FilePath, FilePathWithPrefix, UXFileInfo } from "@vrtmrz/livesync-
import { readAsBlob } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import type { DatabaseFileAccess } from "@vrtmrz/livesync-commonlib/compat/interfaces/DatabaseFileAccess";
import type { IFileHandler } from "@vrtmrz/livesync-commonlib/compat/interfaces/FileHandler";
import { stripAllPrefixes } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
import { parseDocumentPath, splitDocumentPath } from "@vrtmrz/livesync-commonlib/storage";
export type DocumentHistoryRestorationCore = {
databaseFileAccess: Pick<
@@ -54,8 +54,8 @@ export async function restoreDocumentHistoryRevision(
}
const body = readAsBlob(source);
const storagePath = stripAllPrefixes(current.path);
if (storagePath !== current.path || !isPathValid(storagePath)) {
const { prefix, path: storagePath } = splitDocumentPath(parseDocumentPath(current.path));
if (prefix !== "" || !isPathValid(storagePath)) {
return { status: "unsupported-path" };
}
const file: UXFileInfo = {
@@ -17,8 +17,10 @@ export function useInteractiveConflictResolutionFeature(
createDialogue: ConflictResolveDialogueFactory
): void {
const services = host.services;
const lifetime = new AbortController();
const operations = createInteractiveConflictResolutionOperations({
events: services.context.events,
signal: lifetime.signal,
databaseFileAccess: host.serviceModules.databaseFileAccess,
localDatabase: () => services.database.localDatabase,
confirm: services.UI.confirm,
@@ -61,24 +63,18 @@ export function useInteractiveConflictResolutionFeature(
});
services.appLifecycle.getUnresolvedMessages.addHandler(operations.getActiveConflictMessages);
services.conflict.resolveByUserInteraction.addHandler(operations.resolveByUserInteraction);
const eventSubscriptions = new AbortController();
const dispose = () => {
// Stop the refresh listener before cancellation so that unloading does
// not start a database read which can race with database disposal.
eventSubscriptions.abort();
operations.dispose();
};
services.context.events.onEvent(
EVENT_CONFLICT_CANCELLED,
(filename) => {
operations.invalidateWaitingResolution(filename);
fireAndForget(() => operations.refreshConflictState(filename));
},
{ signal: eventSubscriptions.signal }
{ signal: lifetime.signal }
);
services.context.events.onceEvent(EVENT_PLUGIN_UNLOADED, dispose, { signal: eventSubscriptions.signal });
const stop = (): void => lifetime.abort();
services.context.events.onceEvent(EVENT_PLUGIN_UNLOADED, stop, { signal: lifetime.signal });
services.appLifecycle.onUnload.addHandler(() => {
dispose();
stop();
return Promise.resolve(true);
});
}
@@ -1,5 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
import { bindableFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/HandlerUtils";
import { createInstanceLogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
import {
AUTO_MERGED,
CANCELLED,
@@ -30,8 +32,12 @@ async function* documents(items: unknown[]) {
}
}
function createOperations(conflictedRevisions: string[] = ["2-right"]) {
function createOperations(
conflictedRevisions: string[] = ["2-right"],
overrides: Partial<InteractiveConflictResolutionOperationsDependencies> = {}
) {
const context = createServiceContext();
const lifetime = new AbortController();
let dialogueResult: unknown = POSTPONED;
const constructed = { value: 0 };
const getDBEntry = vi.fn(async (): Promise<false | { _rev: string; _conflicts?: string[] }> => false);
@@ -50,6 +56,7 @@ function createOperations(conflictedRevisions: string[] = ["2-right"]) {
const log = vi.fn();
const operations = createInteractiveConflictResolutionOperations({
events: context.events,
signal: overrides.signal ?? lifetime.signal,
databaseFileAccess: {
getConflictedRevs,
storeContent,
@@ -75,8 +82,10 @@ function createOperations(conflictedRevisions: string[] = ["2-right"]) {
};
}),
log,
...overrides,
} as unknown as InteractiveConflictResolutionOperationsDependencies);
return {
abort: () => lifetime.abort(),
askSelectString,
askInPopup,
constructed,
@@ -148,11 +157,13 @@ function createControlledDialogueFactory(
function createDialogueConcurrencyHarness() {
const context = createServiceContext();
const lifetime = new AbortController();
const emitEvent = vi.spyOn(context.events, "emitEvent");
const dialogues: ControlledDialogue[] = [];
const createDialogue = createControlledDialogueFactory(context, dialogues);
const operations = createInteractiveConflictResolutionOperations({
events: context.events,
signal: lifetime.signal,
databaseFileAccess: {
getConflictedRevs: vi.fn(async () => ["2-right"]),
storeContent: vi.fn(async () => true),
@@ -177,10 +188,447 @@ function createDialogueConcurrencyHarness() {
createDialogue: createDialogue as ConflictResolveDialogueFactory,
log: vi.fn(),
} as unknown as InteractiveConflictResolutionOperationsDependencies);
return { context, createDialogue, dialogues, emitEvent, operations };
return { abort: () => lifetime.abort(), context, createDialogue, dialogues, emitEvent, operations };
}
describe("interactive conflict resolution operations", () => {
it("returns cancellation to the caller waiting for a requested conflict check", async () => {
const harness = createOperations();
harness.ensureAllProcessed.mockResolvedValue(false);
await expect(harness.operations.requestConflictResolution(path)).resolves.toBe(false);
expect(harness.queueCheckFor).toHaveBeenCalledWith(path);
});
it.each([
{ label: "cancelled", result: CANCELLED },
{ label: "postponed", result: POSTPONED },
{ label: "selected", result: "2-right" },
] as const)("ignores a $label dialogue result which settles after disposal", async ({ result }) => {
let finish!: (result: MergeDialogResult) => void;
const response = new Promise<MergeDialogResult>((resolve) => {
finish = resolve;
});
const sink = vi.fn();
const addLog = bindableFunction<(message: unknown) => void>("addLog");
const unregisterLog = addLog.setHandler(sink);
const createDialogue = vi.fn(() => ({ open: vi.fn(), waitForResult: () => response }));
const fixture = createOperations(undefined, {
createDialogue,
log: createInstanceLogFunction("Conflict shutdown", { addLog } as never),
});
const active = fixture.operations.resolveByUserInteraction(path, conflict);
await vi.waitFor(() => expect(createDialogue).toHaveBeenCalledOnce());
const waiting = fixture.operations.resolveByUserInteraction("waiting.md" as FilePathWithPrefix, conflict);
const settled = Promise.allSettled([active, waiting]);
const logsBeforeDisposal = sink.mock.calls.length;
fixture.abort();
unregisterLog();
finish(result);
expect(await settled).toEqual([
{ status: "fulfilled", value: false },
{ status: "fulfilled", value: false },
]);
expect(createDialogue).toHaveBeenCalledOnce();
expect(sink).toHaveBeenCalledTimes(logsBeforeDisposal);
expect(fixture.getDBEntry).not.toHaveBeenCalled();
expect(fixture.storeContent).not.toHaveBeenCalled();
expect(fixture.resolveByDeletingRevision).not.toHaveBeenCalled();
expect(fixture.replicateUnattendedByEvent).not.toHaveBeenCalled();
expect(fixture.queueCheckFor).not.toHaveBeenCalled();
});
it("logs a failed database read and returns normally after disposal", async () => {
let finishRead!: (value: false | { _rev: string; _conflicts?: string[] }) => void;
const pendingRead = new Promise<false | { _rev: string; _conflicts?: string[] }>((resolve) => {
finishRead = resolve;
});
const sink = vi.fn();
const addLog = bindableFunction<(message: unknown) => void>("addLog");
addLog.setHandler(sink);
const fixture = createOperations(undefined, {
createDialogue: vi.fn(() => ({
open: vi.fn(),
waitForResult: vi.fn(async () => "2-right"),
})),
log: createInstanceLogFunction("Conflict shutdown", { addLog } as never),
});
fixture.getDBEntry.mockReturnValue(pendingRead);
const resolving = fixture.operations.resolveByUserInteraction(path, conflict);
await vi.waitFor(() => expect(fixture.getDBEntry).toHaveBeenCalledOnce());
const logsBeforeDisposal = sink.mock.calls.length;
fixture.abort();
finishRead(false);
await expect(resolving).resolves.toBe(false);
expect(sink).toHaveBeenCalledTimes(logsBeforeDisposal + 1);
expect(sink).toHaveBeenCalledWith(
expect.stringContaining(`Merge: Could not read ${path} from the local database`),
expect.anything(),
expect.anything()
);
expect(fixture.storeContent).not.toHaveBeenCalled();
expect(fixture.resolveByDeletingRevision).not.toHaveBeenCalled();
expect(fixture.replicateUnattendedByEvent).not.toHaveBeenCalled();
expect(fixture.queueCheckFor).not.toHaveBeenCalled();
});
it("continues a selected conflict after the database read succeeds following disposal", async () => {
let finishRead!: (value: false | { _rev: string; _conflicts?: string[] }) => void;
const pendingRead = new Promise<false | { _rev: string; _conflicts?: string[] }>((resolve) => {
finishRead = resolve;
});
const fixture = createOperations(undefined, {
createDialogue: vi.fn(() => ({
open: vi.fn(),
waitForResult: vi.fn(async () => "2-right"),
})),
});
fixture.getDBEntry.mockReturnValue(pendingRead);
const resolving = fixture.operations.resolveByUserInteraction(path, conflict);
await vi.waitFor(() => expect(fixture.getDBEntry).toHaveBeenCalledOnce());
fixture.abort();
finishRead({ _rev: "2-left", _conflicts: ["2-right"] });
await expect(resolving).resolves.toBe(false);
expect(fixture.resolveByDeletingRevision).toHaveBeenCalledWith(path, "2-right", "UI Selected");
expect(fixture.replicateUnattendedByEvent).not.toHaveBeenCalled();
expect(fixture.queueCheckFor).toHaveBeenCalledWith(path);
});
it("logs a failed content store after disposal", async () => {
let finishStore!: (stored: boolean) => void;
const pendingStore = new Promise<boolean>((resolve) => {
finishStore = resolve;
});
const sink = vi.fn();
const addLog = bindableFunction<(message: unknown) => void>("addLog");
addLog.setHandler(sink);
const fixture = createOperations(undefined, {
log: createInstanceLogFunction("Conflict shutdown", { addLog } as never),
});
fixture.dialogueResult.value = LEAVE_TO_SUBSEQUENT;
fixture.getDBEntry.mockResolvedValue({ _rev: "2-left", _conflicts: ["2-right"] });
fixture.storeContent.mockReturnValue(pendingStore);
const resolving = fixture.operations.resolveByUserInteraction(path, conflict);
await vi.waitFor(() => expect(fixture.storeContent).toHaveBeenCalledOnce());
const logsBeforeDisposal = sink.mock.calls.length;
fixture.abort();
finishStore(false);
await expect(resolving).resolves.toBe(false);
expect(sink).toHaveBeenCalledTimes(logsBeforeDisposal + 1);
expect(sink).toHaveBeenCalledWith(
`Concatenated content cannot be stored:${path}`,
expect.anything(),
expect.anything()
);
expect(fixture.resolveByDeletingRevision).not.toHaveBeenCalled();
expect(fixture.replicateUnattendedByEvent).not.toHaveBeenCalled();
expect(fixture.queueCheckFor).not.toHaveBeenCalled();
});
it("continues a selected conflict after content storage succeeds following disposal", async () => {
let finishStore!: (stored: boolean) => void;
const pendingStore = new Promise<boolean>((resolve) => {
finishStore = resolve;
});
const sink = vi.fn();
const addLog = bindableFunction<(message: unknown) => void>("addLog");
addLog.setHandler(sink);
const fixture = createOperations(undefined, {
log: createInstanceLogFunction("Conflict shutdown", { addLog } as never),
});
fixture.dialogueResult.value = LEAVE_TO_SUBSEQUENT;
fixture.getDBEntry.mockResolvedValue({ _rev: "2-left", _conflicts: ["2-right"] });
fixture.storeContent.mockReturnValue(pendingStore);
fixture.currentSettings.mockReturnValue({ syncAfterMerge: true });
const resolving = fixture.operations.resolveByUserInteraction(path, conflict);
await vi.waitFor(() => expect(fixture.storeContent).toHaveBeenCalledOnce());
const logsBeforeDisposal = sink.mock.calls.length;
fixture.abort();
finishStore(true);
await expect(resolving).resolves.toBe(false);
expect(sink).toHaveBeenCalledTimes(logsBeforeDisposal);
expect(fixture.resolveByDeletingRevision).toHaveBeenCalledWith(path, "2-right", "UI Concatenated");
expect(fixture.replicateUnattendedByEvent).toHaveBeenCalledWith({
trigger: "merge",
interaction: expect.anything(),
});
expect(fixture.queueCheckFor).toHaveBeenCalledWith(path);
});
it("logs a failed revision deletion after disposal", async () => {
let finishDeletion!: (result: typeof AUTO_MERGED | typeof MISSING_OR_ERROR) => void;
const pendingDeletion = new Promise<typeof AUTO_MERGED | typeof MISSING_OR_ERROR>((resolve) => {
finishDeletion = resolve;
});
const sink = vi.fn();
const addLog = bindableFunction<(message: unknown) => void>("addLog");
addLog.setHandler(sink);
const fixture = createOperations(undefined, {
log: createInstanceLogFunction("Conflict shutdown", { addLog } as never),
});
fixture.dialogueResult.value = "2-right";
fixture.getDBEntry.mockResolvedValue({ _rev: "2-left", _conflicts: ["2-right"] });
fixture.resolveByDeletingRevision.mockReturnValue(pendingDeletion);
const resolving = fixture.operations.resolveByUserInteraction(path, conflict);
await vi.waitFor(() => expect(fixture.resolveByDeletingRevision).toHaveBeenCalledOnce());
const logsBeforeDisposal = sink.mock.calls.length;
fixture.abort();
finishDeletion(MISSING_OR_ERROR);
await expect(resolving).resolves.toBe(false);
expect(sink).toHaveBeenCalledTimes(logsBeforeDisposal + 1);
expect(sink).toHaveBeenCalledWith(
expect.stringContaining(`Merge: Something went wrong: ${path}`),
expect.anything(),
expect.anything()
);
expect(fixture.replicateUnattendedByEvent).not.toHaveBeenCalled();
expect(fixture.queueCheckFor).not.toHaveBeenCalled();
});
it("replicates and requeues after revision deletion succeeds following disposal", async () => {
let finishDeletion!: (result: typeof AUTO_MERGED | typeof MISSING_OR_ERROR) => void;
const pendingDeletion = new Promise<typeof AUTO_MERGED | typeof MISSING_OR_ERROR>((resolve) => {
finishDeletion = resolve;
});
const sink = vi.fn();
const addLog = bindableFunction<(message: unknown) => void>("addLog");
addLog.setHandler(sink);
const fixture = createOperations(undefined, {
log: createInstanceLogFunction("Conflict shutdown", { addLog } as never),
});
fixture.dialogueResult.value = "2-right";
fixture.getDBEntry.mockResolvedValue({ _rev: "2-left", _conflicts: ["2-right"] });
fixture.resolveByDeletingRevision.mockReturnValue(pendingDeletion);
fixture.currentSettings.mockReturnValue({ syncAfterMerge: true });
const resolving = fixture.operations.resolveByUserInteraction(path, conflict);
await vi.waitFor(() => expect(fixture.resolveByDeletingRevision).toHaveBeenCalledOnce());
const logsBeforeDisposal = sink.mock.calls.length;
fixture.abort();
finishDeletion(AUTO_MERGED);
await expect(resolving).resolves.toBe(false);
expect(sink).toHaveBeenCalledTimes(logsBeforeDisposal);
expect(fixture.replicateUnattendedByEvent).toHaveBeenCalledWith({
trigger: "merge",
interaction: expect.anything(),
});
expect(fixture.queueCheckFor).toHaveBeenCalledWith(path);
});
it("logs a failed conflict inspection but does not refresh UI after disposal", async () => {
let rejectInspection!: (reason: unknown) => void;
const pendingInspection = new Promise<string[]>((_resolve, reject) => {
rejectInspection = reject;
});
const sink = vi.fn();
const addLog = bindableFunction<(message: unknown) => void>("addLog");
addLog.setHandler(sink);
const fixture = createOperations(undefined, {
log: createInstanceLogFunction("Conflict shutdown", { addLog } as never),
});
fixture.getConflictedRevs.mockReturnValue(pendingInspection);
const emitEvent = vi.spyOn(fixture.context.events, "emitEvent");
const refreshing = fixture.operations.refreshConflictState(path);
await vi.waitFor(() => expect(fixture.getConflictedRevs).toHaveBeenCalledOnce());
const logsBeforeDisposal = sink.mock.calls.length;
fixture.abort();
rejectInspection(new Error("database unavailable"));
await expect(refreshing).resolves.toBeUndefined();
expect(sink).toHaveBeenCalledTimes(logsBeforeDisposal + 2);
expect(sink).toHaveBeenCalledWith(
expect.stringContaining(`Could not inspect the conflict state of ${path}`),
expect.anything(),
expect.anything()
);
expect(sink).toHaveBeenCalledWith(
expect.objectContaining({ message: "database unavailable" }),
expect.anything(),
expect.anything()
);
expect(emitEvent).not.toHaveBeenCalledWith(EVENT_ON_UNRESOLVED_ERROR);
});
it("does not emit a successful conflict refresh after disposal", async () => {
let finishInspection!: (revisions: string[]) => void;
const pendingInspection = new Promise<string[]>((resolve) => {
finishInspection = resolve;
});
const sink = vi.fn();
const addLog = bindableFunction<(message: unknown) => void>("addLog");
addLog.setHandler(sink);
const fixture = createOperations(undefined, {
log: createInstanceLogFunction("Conflict shutdown", { addLog } as never),
});
fixture.getConflictedRevs.mockReturnValue(pendingInspection);
const emitEvent = vi.spyOn(fixture.context.events, "emitEvent");
const refreshing = fixture.operations.refreshConflictState(path);
await vi.waitFor(() => expect(fixture.getConflictedRevs).toHaveBeenCalledOnce());
const logsBeforeDisposal = sink.mock.calls.length;
fixture.abort();
finishInspection(["2-right"]);
await expect(refreshing).resolves.toBeUndefined();
expect(sink).toHaveBeenCalledTimes(logsBeforeDisposal);
expect(emitEvent).not.toHaveBeenCalledWith(EVENT_ON_UNRESOLVED_ERROR);
});
it("returns no active conflict messages when inspection settles after disposal", async () => {
let finishInspection!: (revisions: string[]) => void;
const pendingInspection = new Promise<string[]>((resolve) => {
finishInspection = resolve;
});
const fixture = createOperations(undefined, {
log: vi.fn(),
});
fixture.getConflictedRevs.mockReturnValue(pendingInspection);
const messages = fixture.operations.getActiveConflictMessages();
await vi.waitFor(() => expect(fixture.getConflictedRevs).toHaveBeenCalledOnce());
fixture.abort();
finishInspection(["2-right"]);
await expect(messages).resolves.toEqual([]);
});
it("does not inspect or publish conflict state when called after disposal", async () => {
const fixture = createOperations();
const emit = vi.spyOn(fixture.context.events, "emitEvent");
fixture.abort();
emit.mockClear();
await expect(fixture.operations.getActiveConflictMessages()).resolves.toEqual([]);
await fixture.operations.refreshConflictState(path);
expect(fixture.getConflictedRevs).not.toHaveBeenCalled();
expect(fixture.getActiveFilePath).not.toHaveBeenCalled();
expect(fixture.log).not.toHaveBeenCalled();
expect(emit).not.toHaveBeenCalled();
});
it("does not requeue a changed comparison when its conflict refresh finishes after disposal", async () => {
let finishInspection!: (revisions: string[]) => void;
const pendingInspection = new Promise<string[]>((resolve) => {
finishInspection = resolve;
});
const sink = vi.fn();
const addLog = bindableFunction<(message: unknown) => void>("addLog");
addLog.setHandler(sink);
const fixture = createOperations(undefined, {
log: createInstanceLogFunction("Conflict shutdown", { addLog } as never),
});
fixture.dialogueResult.value = "2-right";
fixture.getDBEntry.mockResolvedValue({ _rev: "3-new-left", _conflicts: ["2-right"] });
fixture.getConflictedRevs.mockReturnValue(pendingInspection);
const emit = vi.spyOn(fixture.context.events, "emitEvent");
const resolving = fixture.operations.resolveByUserInteraction(path, conflict);
await vi.waitFor(() => expect(fixture.getConflictedRevs).toHaveBeenCalledOnce());
const logsBeforeDisposal = sink.mock.calls.length;
fixture.abort();
emit.mockClear();
finishInspection(["2-right"]);
await expect(resolving).resolves.toBe(false);
expect(sink).toHaveBeenCalledTimes(logsBeforeDisposal);
expect(emit).not.toHaveBeenCalled();
expect(fixture.storeContent).not.toHaveBeenCalled();
expect(fixture.resolveByDeletingRevision).not.toHaveBeenCalled();
expect(fixture.queueCheckFor).not.toHaveBeenCalled();
});
it.each([AUTO_MERGED, MISSING_OR_ERROR] as const)(
"continues concatenated resolution according to deletion result after disposal (%s)",
async (result) => {
let finishDeletion!: (value: typeof AUTO_MERGED | typeof MISSING_OR_ERROR) => void;
const pendingDeletion = new Promise<typeof AUTO_MERGED | typeof MISSING_OR_ERROR>((resolve) => {
finishDeletion = resolve;
});
const sink = vi.fn();
const addLog = bindableFunction<(message: unknown) => void>("addLog");
addLog.setHandler(sink);
const fixture = createOperations(undefined, {
log: createInstanceLogFunction("Conflict shutdown", { addLog } as never),
});
fixture.dialogueResult.value = LEAVE_TO_SUBSEQUENT;
fixture.getDBEntry.mockResolvedValue({ _rev: "2-left", _conflicts: ["2-right"] });
fixture.resolveByDeletingRevision.mockReturnValue(pendingDeletion);
fixture.currentSettings.mockReturnValue({ syncAfterMerge: true });
const resolving = fixture.operations.resolveByUserInteraction(path, conflict);
await vi.waitFor(() => expect(fixture.resolveByDeletingRevision).toHaveBeenCalledOnce());
expect(fixture.storeContent).toHaveBeenCalledOnce();
const logsBeforeDisposal = sink.mock.calls.length;
fixture.abort();
finishDeletion(result);
await expect(resolving).resolves.toBe(false);
expect(fixture.resolveByDeletingRevision).toHaveBeenCalledTimes(1);
if (result === MISSING_OR_ERROR) {
expect(sink).toHaveBeenCalledTimes(logsBeforeDisposal + 1);
expect(sink).toHaveBeenCalledWith(
expect.stringContaining("Concatenated saved, but cannot delete conflicted revisions:"),
expect.anything(),
expect.anything()
);
expect(fixture.replicateUnattendedByEvent).not.toHaveBeenCalled();
expect(fixture.queueCheckFor).not.toHaveBeenCalled();
} else {
expect(sink).toHaveBeenCalledTimes(logsBeforeDisposal);
expect(fixture.replicateUnattendedByEvent).toHaveBeenCalledWith({
trigger: "merge",
interaction: expect.anything(),
});
expect(fixture.queueCheckFor).toHaveBeenCalledWith(path);
}
}
);
it("requeues after in-flight replication completes following disposal", async () => {
let finishReplication!: (result: { status: "completed" }) => void;
const pendingReplication = new Promise<{ status: "completed" }>((resolve) => {
finishReplication = resolve;
});
const fixture = createOperations();
fixture.dialogueResult.value = "2-right";
fixture.getDBEntry.mockResolvedValue({ _rev: "2-left", _conflicts: ["2-right"] });
fixture.currentSettings.mockReturnValue({ syncAfterMerge: true });
fixture.replicateUnattendedByEvent.mockReturnValue(pendingReplication);
const resolving = fixture.operations.resolveByUserInteraction(path, conflict);
await vi.waitFor(() => expect(fixture.replicateUnattendedByEvent).toHaveBeenCalledOnce());
fixture.abort();
finishReplication({ status: "completed" });
await expect(resolving).resolves.toBe(false);
expect(fixture.resolveByDeletingRevision).toHaveBeenCalledOnce();
expect(fixture.replicateUnattendedByEvent).toHaveBeenCalledTimes(1);
expect(fixture.queueCheckFor).toHaveBeenCalledWith(path);
});
it("replaces an active same-file dialogue with only the newest waiting request", async () => {
const fixture = createDialogueConcurrencyHarness();
@@ -233,6 +681,40 @@ describe("interactive conflict resolution operations", () => {
await Promise.all([first, waiting]);
});
it("keeps another instance's active and waiting dialogues when disposal is repeated", async () => {
const oldInstance = createDialogueConcurrencyHarness();
const currentInstance = createDialogueConcurrencyHarness();
const oldActive = oldInstance.operations.resolveByUserInteraction(path, conflict);
await vi.waitFor(() => expect(oldInstance.dialogues).toHaveLength(1));
const oldWaiting = oldInstance.operations.resolveByUserInteraction(
"old-waiting.md" as FilePathWithPrefix,
conflict
);
const currentActive = currentInstance.operations.resolveByUserInteraction(path, conflict);
await vi.waitFor(() => expect(currentInstance.dialogues).toHaveLength(1));
const currentWaiting = currentInstance.operations.resolveByUserInteraction(
"current-waiting.md" as FilePathWithPrefix,
conflict
);
await Promise.resolve();
oldInstance.abort();
oldInstance.abort();
await expect(Promise.all([oldActive, oldWaiting])).resolves.toEqual([false, false]);
expect(oldInstance.dialogues).toHaveLength(1);
expect(currentInstance.dialogues).toHaveLength(1);
expect(currentInstance.emitEvent).not.toHaveBeenCalledWith(EVENT_CONFLICT_CANCELLED, path);
currentInstance.dialogues[0].finish(CANCELLED);
await vi.waitFor(() => expect(currentInstance.dialogues).toHaveLength(2));
expect(currentInstance.dialogues[1].filename).toBe("current-waiting.md");
currentInstance.dialogues[1].finish(CANCELLED);
await Promise.all([currentActive, currentWaiting]);
});
it("does not reopen an unchanged conflict after the user chooses Not now", async () => {
const { constructed, operations } = createOperations();
@@ -770,6 +1252,7 @@ describe("interactive conflict resolution feature composition", () => {
expect(completedOnUnload).toBe(true);
expect(dialogues).toHaveLength(1);
expect(fixture.getConflictedRevs).not.toHaveBeenCalled();
await fixture.handlers.onUnload?.();
});
@@ -23,6 +23,8 @@ import type { LiveSyncLocalDB } from "@vrtmrz/livesync-commonlib/compat/pouchdb/
import type { LiveSyncEventHub } from "@vrtmrz/livesync-commonlib/context";
import { fireAndForget } from "octagonal-wheels/promises";
import { withLock } from "octagonal-wheels/concurrency/lock";
import { withCancellation } from "octagonal-wheels/concurrency/cancellation";
import { withScope } from "octagonal-wheels/resource/scope";
import { NO_INTERACTION } from "@vrtmrz/livesync-commonlib/replication";
import { displayRev } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import { EVENT_CONFLICT_CANCELLED, EVENT_ON_UNRESOLVED_ERROR } from "@/common/events.ts";
@@ -38,6 +40,7 @@ import {
export interface InteractiveConflictResolutionOperationsDependencies {
readonly events: Pick<LiveSyncEventHub, "emitEvent">;
readonly signal: AbortSignal;
readonly databaseFileAccess: Pick<DatabaseFileAccess, "getConflictedRevs" | "storeContent">;
readonly localDatabase: () => Pick<LiveSyncLocalDB, "getDBEntry" | "findAllDocs">;
readonly confirm: Pick<Confirm, "askSelectString" | "askInPopup">;
@@ -52,11 +55,11 @@ export interface InteractiveConflictResolutionOperationsDependencies {
}
export interface InteractiveConflictResolutionOperations {
readonly dispose: () => void;
readonly invalidateWaitingResolution: (filename: FilePathWithPrefix) => void;
readonly getActiveConflictMessages: () => Promise<string[]>;
readonly refreshConflictState: (filename: FilePathWithPrefix) => Promise<void>;
readonly requestConflictResolution: (filename: FilePathWithPrefix) => Promise<void>;
/** Returns false if the requested conflict check was cancelled. */
readonly requestConflictResolution: (filename: FilePathWithPrefix) => Promise<boolean>;
readonly resolveByUserInteraction: (
filename: FilePathWithPrefix,
conflictCheckResult: diff_result
@@ -76,9 +79,18 @@ export function createInteractiveConflictResolutionOperations(
const latestRequestByFilename = new Map<FilePathWithPrefix, number>();
let nextRequestId = 0;
let activeDialogue: { filename: FilePathWithPrefix; dialogue: ConflictResolveDialogue } | undefined;
let disposed = false;
const clearPendingState = (): void => {
latestRequestByFilename.clear();
postponedConflictEpisodes.clear();
};
if (dependencies.signal.aborted) {
clearPendingState();
} else {
dependencies.signal.addEventListener("abort", clearPendingState, { once: true });
}
const invalidateWaitingResolution = (filename: FilePathWithPrefix): void => {
if (dependencies.signal.aborted) return;
// An active dialogue consumes this event itself. Removing its request
// here would also remove a same-path replacement which emitted the
// event to close that active dialogue. A non-active request is stale
@@ -87,21 +99,11 @@ export function createInteractiveConflictResolutionOperations(
latestRequestByFilename.delete(filename);
};
const dispose = (): void => {
if (disposed) return;
disposed = true;
latestRequestByFilename.clear();
postponedConflictEpisodes.clear();
const activeFilename = activeDialogue?.filename;
if (activeFilename !== undefined) {
dependencies.events.emitEvent(EVENT_CONFLICT_CANCELLED, activeFilename);
activeDialogue = undefined;
}
};
const getConflictVersionCount = async (filename: FilePathWithPrefix): Promise<number | undefined> => {
if (dependencies.signal.aborted) return undefined;
try {
const conflictCount = (await dependencies.databaseFileAccess.getConflictedRevs(filename)).length;
if (dependencies.signal.aborted) return undefined;
return conflictCount === 0 ? 0 : conflictCount + 1;
} catch (error) {
dependencies.log(`Could not inspect the conflict state of ${filename}`, LOG_LEVEL_VERBOSE);
@@ -111,9 +113,11 @@ export function createInteractiveConflictResolutionOperations(
};
const getActiveConflictMessages = async (): Promise<string[]> => {
if (dependencies.signal.aborted) return [];
const filename = dependencies.vault.getActiveFilePath();
if (!filename) return [];
const versionCount = await getConflictVersionCount(filename);
if (dependencies.signal.aborted) return [];
if (versionCount === 0) {
postponedConflictEpisodes.delete(filename);
return [];
@@ -132,39 +136,50 @@ export function createInteractiveConflictResolutionOperations(
};
const refreshConflictState = async (filename: FilePathWithPrefix): Promise<void> => {
if ((await getConflictVersionCount(filename)) === 0) {
if (dependencies.signal.aborted) return;
const versionCount = await getConflictVersionCount(filename);
if (dependencies.signal.aborted) return;
if (versionCount === 0) {
postponedConflictEpisodes.delete(filename);
}
dependencies.events.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
};
const requestConflictResolution = async (filename: FilePathWithPrefix): Promise<void> => {
const requestConflictResolution = async (filename: FilePathWithPrefix): Promise<boolean> => {
if (dependencies.signal.aborted) return false;
postponedConflictEpisodes.delete(filename);
dependencies.events.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
await dependencies.conflict.queueCheckFor(filename);
await dependencies.conflict.ensureAllProcessed();
if (dependencies.signal.aborted) return false;
return await dependencies.conflict.ensureAllProcessed();
};
const resolveByUserInteraction = async (
filename: FilePathWithPrefix,
conflictCheckResult: diff_result
): Promise<boolean> => {
if (disposed) return false;
const requestId = ++nextRequestId;
if (activeDialogue?.filename === filename) {
dependencies.events.emitEvent(EVENT_CONFLICT_CANCELLED, filename);
activeDialogue = undefined;
}
latestRequestByFilename.set(filename, requestId);
// UI for resolving different files should proceed one-by-one. A newer
// request for the active file replaces its dialogue instead of waiting
// behind a comparison which is already stale.
return await withLock(dialogueSerialisationKey, async () => {
if (disposed || latestRequestByFilename.get(filename) !== requestId) {
return false;
if (dependencies.signal.aborted) return false;
return await withScope(undefined, async (request) => {
if (dependencies.signal.aborted) return false;
const requestId = ++nextRequestId;
if (activeDialogue?.filename === filename) {
dependencies.events.emitEvent(EVENT_CONFLICT_CANCELLED, filename);
activeDialogue = undefined;
}
try {
latestRequestByFilename.set(filename, requestId);
request.defer(() => {
if (latestRequestByFilename.get(filename) === requestId) {
latestRequestByFilename.delete(filename);
}
});
// UI for resolving different files should proceed one-by-one. A newer
// request for the active file replaces its dialogue instead of waiting
// behind a comparison which is already stale.
return await withLock(dialogueSerialisationKey, async () => {
if (dependencies.signal.aborted || latestRequestByFilename.get(filename) !== requestId) {
return false;
}
if (postponedConflictEpisodes.has(filename)) {
dependencies.log(`Merge: Postponed ${filename}`, LOG_LEVEL_VERBOSE);
dependencies.events.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
@@ -175,13 +190,24 @@ export function createInteractiveConflictResolutionOperations(
activeDialogue = { filename, dialogue };
let selected: MergeDialogResult;
try {
dialogue.open();
selected = await dialogue.waitForResult();
selected = await withCancellation(dialogue, { signal: dependencies.signal }, async (waiting) => {
const cancelDialogue = (): void => {
if (activeDialogue?.dialogue === dialogue) {
dependencies.events.emitEvent(EVENT_CONFLICT_CANCELLED, filename);
}
};
waiting.signal.addEventListener("abort", cancelDialogue, { once: true });
waiting.defer(() => waiting.signal.removeEventListener("abort", cancelDialogue));
dialogue.open();
if (waiting.isCancelled()) cancelDialogue();
return await dialogue.waitForResult();
});
} finally {
if (activeDialogue?.dialogue === dialogue) {
activeDialogue = undefined;
}
}
if (dependencies.signal.aborted) return false;
if (selected === POSTPONED) {
postponedConflictEpisodes.add(filename);
dependencies.events.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
@@ -203,6 +229,7 @@ export function createInteractiveConflictResolutionOperations(
if (!testDoc._conflicts || testDoc._conflicts.length === 0) {
dependencies.log(`Merge: Nothing to do ${filename}`, LOG_LEVEL_VERBOSE);
await refreshConflictState(filename);
if (dependencies.signal.aborted) return false;
return false;
}
if (
@@ -214,6 +241,7 @@ export function createInteractiveConflictResolutionOperations(
LOG_LEVEL_INFO
);
await refreshConflictState(filename);
if (dependencies.signal.aborted) return false;
await dependencies.conflict.queueCheckFor(filename);
return false;
}
@@ -224,15 +252,18 @@ export function createInteractiveConflictResolutionOperations(
// Create a new file by concatenating both conflicted revisions.
const p = conflictCheckResult.diff.map((e) => e[1]).join("");
const delRev = conflictCheckResult.right.rev;
if (!(await dependencies.databaseFileAccess.storeContent(filename, p))) {
const contentStored = await dependencies.databaseFileAccess.storeContent(filename, p);
if (!contentStored) {
dependencies.log(`Concatenated content cannot be stored:${filename}`, LOG_LEVEL_NOTICE);
return false;
}
// 2. As usual, delete the conflicted revision and if there are no conflicts, write the resolved content to the storage.
if (
(await dependencies.conflict.resolveByDeletingRevision(filename, delRev, "UI Concatenated")) ==
MISSING_OR_ERROR
) {
const deletionResult = await dependencies.conflict.resolveByDeletingRevision(
filename,
delRev,
"UI Concatenated"
);
if (deletionResult == MISSING_OR_ERROR) {
dependencies.log(
`Concatenated saved, but cannot delete conflicted revisions: ${filename}, (${displayRev(delRev)})`,
LOG_LEVEL_NOTICE
@@ -244,10 +275,12 @@ export function createInteractiveConflictResolutionOperations(
(toDelete === conflictCheckResult.left.rev || toDelete === conflictCheckResult.right.rev)
) {
// Select one of the conflicted revision to delete.
if (
(await dependencies.conflict.resolveByDeletingRevision(filename, toDelete, "UI Selected")) ==
MISSING_OR_ERROR
) {
const deletionResult = await dependencies.conflict.resolveByDeletingRevision(
filename,
toDelete,
"UI Selected"
);
if (deletionResult == MISSING_OR_ERROR) {
dependencies.log(`Merge: Something went wrong: ${filename}, (${toDelete})`, LOG_LEVEL_NOTICE);
return false;
}
@@ -270,17 +303,15 @@ export function createInteractiveConflictResolutionOperations(
// And, check it again.
await dependencies.conflict.queueCheckFor(filename);
return false;
} finally {
if (latestRequestByFilename.get(filename) === requestId) {
latestRequestByFilename.delete(filename);
}
}
});
});
};
const pickFileForResolve = async (notifyIfEmpty = true): Promise<boolean> => {
if (dependencies.signal.aborted) return false;
const notes: { id: DocumentID; path: FilePathWithPrefix; dispPath: string; mtime: number }[] = [];
for await (const doc of dependencies.localDatabase().findAllDocs({ conflicts: true })) {
if (dependencies.signal.aborted) return false;
if (!("_conflicts" in doc)) continue;
notes.push({
id: doc._id,
@@ -289,6 +320,7 @@ export function createInteractiveConflictResolutionOperations(
mtime: doc.mtime,
});
}
if (dependencies.signal.aborted) return false;
notes.sort((a, b) => b.mtime - a.mtime);
const notesList = notes.map((e) => e.dispPath);
if (notesList.length == 0) {
@@ -298,6 +330,7 @@ export function createInteractiveConflictResolutionOperations(
return false;
}
const target = await dependencies.confirm.askSelectString("File to resolve conflict", notesList);
if (dependencies.signal.aborted) return false;
if (target) {
const targetItem = notes.find((e) => e.dispPath == target)!;
await requestConflictResolution(targetItem.path);
@@ -308,19 +341,22 @@ export function createInteractiveConflictResolutionOperations(
const allConflictCheck = async (): Promise<void> => {
let notifyIfEmpty = true;
while (await pickFileForResolve(notifyIfEmpty)) {
while (!dependencies.signal.aborted && (await pickFileForResolve(notifyIfEmpty))) {
notifyIfEmpty = false;
}
};
const scanStartupIssues = async (): Promise<boolean> => {
if (dependencies.signal.aborted) return false;
const notes: { path: string; mtime: number }[] = [];
dependencies.log(`Checking conflicted files`, LOG_LEVEL_VERBOSE);
try {
for await (const doc of dependencies.localDatabase().findAllDocs({ conflicts: true })) {
if (dependencies.signal.aborted) return false;
if (!("_conflicts" in doc)) continue;
notes.push({ path: dependencies.path.getPath(doc), mtime: doc.mtime });
}
if (dependencies.signal.aborted) return false;
if (notes.length > 0) {
dependencies.confirm.askInPopup(
`conflicting-detected-on-safety`,
@@ -351,7 +387,6 @@ export function createInteractiveConflictResolutionOperations(
};
return {
dispose,
invalidateWaitingResolution,
getActiveConflictMessages,
refreshConflictState,
@@ -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);