mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-11-06 16:28:18 +00:00
Merge prerequisite changes into Japanese localisation
This commit is contained in:
commit
49120cf5e0
98 files changed
+8487
-1233
No files matched your search
+37
-3
@@ -80,6 +80,7 @@ livesync-cli [database-path] [command] [args...]
|
||||
### Commands
|
||||
|
||||
- `sync`: Run one replication cycle with the remote CouchDB.
|
||||
- `journal-skip <journal-key>`: Retry replication, skip only the exact currently stopped Object Storage Journal, and resume.
|
||||
- `mirror [vault-path]`: Bidirectional sync between the local database and a local directory (**the actual vault**).
|
||||
- If `vault-path` is provided, the CLI will synchronise the database with files in the vault directory.
|
||||
- If `vault-path` is omitted, it defaults to `database-path` (compatibility mode).
|
||||
@@ -108,6 +109,32 @@ livesync-cli [database-path] [command] [args...]
|
||||
|
||||
Remote-administration commands verify the resulting milestone state through the selected provider. The existing `[Verification]` lines remain suitable for scripts which inspect command output, while the default exit code now reflects whether that verification succeeded.
|
||||
|
||||
### Object Storage receive failures
|
||||
|
||||
`sync` stops at a Journal which cannot be processed or whose records cannot be
|
||||
saved. After correcting the cause, run `sync` again with the same database and
|
||||
settings to retry it. Existing partial writes are retained. `fetch --force`
|
||||
replaces the local database instead and follows the reconstruction rules below.
|
||||
|
||||
A failed `sync` prints `[Error] Command 'sync' failed` and exits with code `1`.
|
||||
To inspect the Journal key, failure stage, and diagnostic details, retry with
|
||||
`--debug`, for example `livesync-cli ./my-db --debug sync`, retaining the same
|
||||
database and settings. Debug diagnostics are written to standard error.
|
||||
|
||||
If the Journal cannot be recovered and missing content or history is
|
||||
acceptable, run `journal-skip <journal-key>`. The CLI first performs normal
|
||||
replication with the retained local database. It skips only when that attempt
|
||||
stops at the exact supplied key; if replication succeeds or stops at another
|
||||
Journal, the command refuses the explicit skip. A successful normal attempt
|
||||
can still acknowledge Journals which it processed successfully.
|
||||
After the matching Journal is skipped, replication resumes. Any later failed
|
||||
Journal still stops, so each additional skip requires a separate command with
|
||||
that Journal's key. Normal identity, connectivity, authentication, and
|
||||
readiness checks still apply. This action does not persist a setting for future
|
||||
replication. Review the resulting file contents and retained history; a
|
||||
completed receive does not guarantee that later file reflection or reads will
|
||||
succeed.
|
||||
|
||||
### Interrupted reconstruction and older runtime state
|
||||
|
||||
An interrupted `fetch` or `rebuild` leaves a record in
|
||||
@@ -146,6 +173,7 @@ scopes do not make concurrent writers to shared device settings supported.
|
||||
```bash
|
||||
# Basic sync with remote
|
||||
livesync-cli ./my-db sync
|
||||
livesync-cli ./my-db journal-skip 7-docs.jsonl.gz
|
||||
|
||||
# Mirroring to your actual Obsidian vault
|
||||
livesync-cli ./my-db mirror /path/to/obsidian-vault
|
||||
@@ -418,7 +446,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 +471,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
|
||||
|
||||
@@ -471,6 +503,8 @@ Commands:
|
||||
daemon (default) Run mirror scan then continuously sync CouchDB <-> local filesystem
|
||||
init-settings [path] Create unconfigured settings JSON with the new-Vault recommendations
|
||||
sync Run one replication cycle and exit
|
||||
journal-skip <journal-key>
|
||||
Retry replication, skip only the matching stopped Journal, and resume
|
||||
p2p-peers <timeout> Show discovered peers as [peer]<TAB><peer-id><TAB><peer-name>
|
||||
p2p-sync <peer> <timeout> Synchronise with specified peer-id or peer-name
|
||||
p2p-host Start P2P host mode and wait until interrupted (Ctrl+C)
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { NodeStorageAdapter, fsPromises, os, path } from "@vrtmrz/livesync-commonlib/node";
|
||||
import type { FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { StandardIo } from "@vrtmrz/livesync-commonlib/context";
|
||||
import { stripAllPrefixes } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
|
||||
import { createDefaultCliSettings } from "@/apps/cli/cliSettingsDefaults";
|
||||
import { runCommand } from "@/apps/cli/commands/runCommand";
|
||||
import type { CLICommand, CLICommandContext } from "@/apps/cli/commands/types";
|
||||
import { main, type CliCommandRunner } from "@/apps/cli/main";
|
||||
|
||||
type StoredFileSnapshot = {
|
||||
id: string;
|
||||
revision: string;
|
||||
path: string;
|
||||
size: number;
|
||||
children: string[];
|
||||
content: string;
|
||||
};
|
||||
|
||||
describe("CLI NFC scan failure recovery", () => {
|
||||
const originalArgv = process.argv.slice();
|
||||
const originalExitCode = process.exitCode;
|
||||
const signalHandlers = new Map<"SIGINT" | "SIGTERM", Set<Parameters<typeof process.removeListener>[1]>>();
|
||||
let directory: string;
|
||||
let vaultPath: string;
|
||||
let settingsPath: string;
|
||||
let standardIo: StandardIo;
|
||||
|
||||
afterEach(async () => {
|
||||
for (const [signal, originalHandlers] of signalHandlers) {
|
||||
for (const handler of process.listeners(signal)) {
|
||||
if (!originalHandlers.has(handler)) process.removeListener(signal, handler);
|
||||
}
|
||||
}
|
||||
signalHandlers.clear();
|
||||
process.argv = originalArgv.slice();
|
||||
process.exitCode = originalExitCode;
|
||||
vi.restoreAllMocks();
|
||||
if (directory) await fsPromises.rm(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function start(command: CLICommand, runner: CliCommandRunner, exitCode: number) {
|
||||
process.exitCode = undefined;
|
||||
process.argv = [
|
||||
"node",
|
||||
"livesync-cli",
|
||||
path.join(directory, "db"),
|
||||
"--vault",
|
||||
vaultPath,
|
||||
"--settings",
|
||||
settingsPath,
|
||||
command,
|
||||
];
|
||||
await expect(main(standardIo, runner)).rejects.toThrow(`__EXIT__:${exitCode}`);
|
||||
}
|
||||
|
||||
async function snapshotFile(context: CLICommandContext, targetPath: string): Promise<StoredFileSnapshot> {
|
||||
for await (const doc of context.core.services.database.localDatabase.findAllNormalDocs({ conflicts: true }) || []) {
|
||||
if (stripAllPrefixes(doc.path) !== targetPath) continue;
|
||||
const file = await context.core.serviceModules.databaseFileAccess.fetch(targetPath as FilePathWithPrefix);
|
||||
if (!file || file.deleted) throw new Error(`Expected readable database content for ${targetPath}`);
|
||||
const body = file.body;
|
||||
if (body.type !== "text/plain") throw new Error(`Expected text database content for ${targetPath}`);
|
||||
return {
|
||||
id: doc._id,
|
||||
revision: doc._rev ?? "",
|
||||
path: doc.path,
|
||||
size: doc.size,
|
||||
children: [...doc.children],
|
||||
content: await body.text(),
|
||||
};
|
||||
}
|
||||
throw new Error(`Expected a stored Metadata document for ${targetPath}`);
|
||||
}
|
||||
|
||||
it("does not accept a partially enumerated Vault as complete and keeps its PouchDB Metadata", async () => {
|
||||
directory = await fsPromises.mkdtemp(path.join(os.tmpdir(), "livesync-cli-nfc-scan-integration-"));
|
||||
vaultPath = path.join(directory, "vault");
|
||||
settingsPath = path.join(directory, "settings.json");
|
||||
const databasePath = path.join(directory, "db");
|
||||
const knownPath = "notes/known.md";
|
||||
const blockedPath = "notes/blocked/new.md";
|
||||
const knownContent = "stored before the failed inventory\n";
|
||||
const blockedContent = "available after recovery\n";
|
||||
|
||||
await fsPromises.mkdir(path.join(vaultPath, "notes"), { recursive: true });
|
||||
await fsPromises.mkdir(databasePath);
|
||||
await fsPromises.writeFile(path.join(vaultPath, knownPath), knownContent);
|
||||
await fsPromises.writeFile(
|
||||
settingsPath,
|
||||
JSON.stringify({
|
||||
...createDefaultCliSettings(),
|
||||
isConfigured: true,
|
||||
pathNormalizationVersion: 1,
|
||||
})
|
||||
);
|
||||
standardIo = {
|
||||
readStdin: vi.fn(async () => ""),
|
||||
prompt: vi.fn(async () => ""),
|
||||
writeStdout: vi.fn(),
|
||||
writeStderr: vi.fn(),
|
||||
};
|
||||
for (const signal of ["SIGINT", "SIGTERM"] as const) {
|
||||
signalHandlers.set(signal, new Set(process.listeners(signal)));
|
||||
}
|
||||
vi.spyOn(process, "exit").mockImplementation((code) => {
|
||||
throw new Error(`__EXIT__:${code ?? 0}`);
|
||||
});
|
||||
|
||||
let seeded: StoredFileSnapshot | undefined;
|
||||
const seedMirror = vi.fn<CliCommandRunner>(async (options, context) => {
|
||||
const result = await runCommand(options, context);
|
||||
seeded = await snapshotFile(context, knownPath);
|
||||
return result;
|
||||
});
|
||||
await start("mirror", seedMirror, 0);
|
||||
expect(seedMirror).toHaveBeenCalledOnce();
|
||||
expect(seeded).toMatchObject({ path: knownPath, content: knownContent });
|
||||
|
||||
await fsPromises.mkdir(path.join(vaultPath, "notes", "blocked"));
|
||||
await fsPromises.writeFile(path.join(vaultPath, blockedPath), blockedContent);
|
||||
|
||||
const scanFailure = Object.assign(new Error("Storage permission denied"), { code: "EACCES" });
|
||||
const directoryReads: string[] = [];
|
||||
const parentDirectoryEntries: string[] = [];
|
||||
// Retain the prototype implementation before the spy replaces it; invoke it with each real adapter below.
|
||||
const originalReadDirectory = Reflect.get(
|
||||
NodeStorageAdapter.prototype,
|
||||
"readDirectory"
|
||||
);
|
||||
const readDirectory = vi.spyOn(NodeStorageAdapter.prototype, "readDirectory").mockImplementation(
|
||||
async function (this: NodeStorageAdapter, relativePath) {
|
||||
directoryReads.push(relativePath);
|
||||
if (relativePath === "notes/blocked") throw scanFailure;
|
||||
const entries = await originalReadDirectory.call(this, relativePath);
|
||||
if (relativePath === "notes") {
|
||||
parentDirectoryEntries.push(...(entries ?? []).map((entry) => entry.name));
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
);
|
||||
const failedMirror = vi.fn<CliCommandRunner>(async (options, context) => await runCommand(options, context));
|
||||
try {
|
||||
await start("mirror", failedMirror, 1);
|
||||
} finally {
|
||||
readDirectory.mockRestore();
|
||||
}
|
||||
|
||||
expect(failedMirror).not.toHaveBeenCalled();
|
||||
expect(directoryReads).toContain("");
|
||||
expect(directoryReads).toContain("notes");
|
||||
expect(directoryReads).toContain("notes/blocked");
|
||||
expect(parentDirectoryEntries).toContain("known.md");
|
||||
expect(parentDirectoryEntries).toContain("blocked");
|
||||
|
||||
let afterFailure: StoredFileSnapshot | undefined;
|
||||
const inspectAfterFailure = vi.fn<CliCommandRunner>(async (options, context) => {
|
||||
const result = await runCommand(options, context);
|
||||
afterFailure = await snapshotFile(context, knownPath);
|
||||
return result;
|
||||
});
|
||||
await start("ls", inspectAfterFailure, 0);
|
||||
expect(afterFailure).toEqual(seeded);
|
||||
expect(await fsPromises.readFile(path.join(vaultPath, knownPath), "utf8")).toBe(knownContent);
|
||||
expect(await fsPromises.readFile(path.join(vaultPath, blockedPath), "utf8")).toBe(blockedContent);
|
||||
|
||||
let recoveredKnown: StoredFileSnapshot | undefined;
|
||||
let recoveredNewFile: StoredFileSnapshot | undefined;
|
||||
const recoverMirror = vi.fn<CliCommandRunner>(async (options, context) => {
|
||||
const result = await runCommand(options, context);
|
||||
recoveredKnown = await snapshotFile(context, knownPath);
|
||||
recoveredNewFile = await snapshotFile(context, blockedPath);
|
||||
return result;
|
||||
});
|
||||
await start("mirror", recoverMirror, 0);
|
||||
expect(recoverMirror).toHaveBeenCalledOnce();
|
||||
expect(recoveredKnown).toEqual(seeded);
|
||||
expect(recoveredNewFile).toMatchObject({ path: blockedPath, content: blockedContent });
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,12 @@ import type { NodeFile, NodeFolder, NodeStat } from "./NodeTypes";
|
||||
import { path } from "@vrtmrz/livesync-commonlib/node";
|
||||
import type { CliDiagnosticReporter } from "@/apps/cli/cliOutput";
|
||||
import { findCaseInsensitiveFile } from "@/apps/caseInsensitiveFileLookup";
|
||||
import {
|
||||
createStoragePathResolver,
|
||||
parseFilePath,
|
||||
type StoragePath,
|
||||
type StoragePathResolver,
|
||||
} from "@vrtmrz/livesync-commonlib/storage";
|
||||
|
||||
/**
|
||||
* Complete file system adapter implementation for Node.js
|
||||
@@ -21,6 +27,7 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
|
||||
readonly vault: NodeVaultAdapter;
|
||||
|
||||
private fileCache = new Map<string, NodeFile>();
|
||||
private readonly isCaseInsensitiveSetting: () => boolean;
|
||||
|
||||
constructor(
|
||||
private basePath: string,
|
||||
@@ -31,6 +38,7 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
|
||||
this.path = new NodePathAdapter();
|
||||
this.typeGuard = new NodeTypeGuardAdapter();
|
||||
this.conversion = new NodeConversionAdapter();
|
||||
this.isCaseInsensitiveSetting = isCaseInsensitive;
|
||||
this.storage = new UnicodeResolvingNodeStorageAdapter(basePath, getPathNormalizationVersion, isCaseInsensitive);
|
||||
this.vault = new NodeVaultAdapter(this.storage);
|
||||
}
|
||||
@@ -39,8 +47,8 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
|
||||
return path.join(this.basePath, p);
|
||||
}
|
||||
|
||||
private normalisePath(p: FilePath | string): string {
|
||||
return this.path.normalisePath(p);
|
||||
private normalisePath(p: FilePath | string): FilePath {
|
||||
return parseFilePath(this.path.normalisePath(p));
|
||||
}
|
||||
|
||||
private async hasExactPathCase(pathStr: string): Promise<boolean> {
|
||||
@@ -122,15 +130,28 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
|
||||
|
||||
async refreshFile(p: string, caseInsensitive = false): Promise<NodeFile | null> {
|
||||
const pathStr = this.normalisePath(p);
|
||||
if (this.storage.isUnicodePathNormalizationEnabled) {
|
||||
try {
|
||||
const resolvedPath = await this.storage.resolveLogicalPath(pathStr, caseInsensitive, false);
|
||||
if (resolvedPath === null) {
|
||||
this.fileCache.delete(pathStr);
|
||||
return null;
|
||||
}
|
||||
return await this.refreshResolvedFile(resolvedPath);
|
||||
} catch (error) {
|
||||
this.fileCache.delete(pathStr);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const resolvedPath = this.storage.isUnicodePathNormalizationEnabled
|
||||
? await this.storage.resolveLogicalPath(pathStr, caseInsensitive, false)
|
||||
: pathStr;
|
||||
if (resolvedPath === null) {
|
||||
const stat = await this.storage.stat(pathStr, caseInsensitive);
|
||||
if (stat?.type !== "file") {
|
||||
this.fileCache.delete(pathStr);
|
||||
return null;
|
||||
}
|
||||
return await this.refreshResolvedFile(resolvedPath);
|
||||
const file: NodeFile = { path: pathStr, stat };
|
||||
this.fileCache.set(pathStr, file);
|
||||
return file;
|
||||
} catch (error) {
|
||||
if (error instanceof AmbiguousNormalisedPathError) throw error;
|
||||
this.fileCache.delete(pathStr);
|
||||
@@ -138,16 +159,16 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
|
||||
}
|
||||
}
|
||||
|
||||
private async refreshResolvedFile(resolvedPath: string): Promise<NodeFile | null> {
|
||||
private async refreshResolvedFile(resolvedPath: StoragePath): Promise<NodeFile | null> {
|
||||
try {
|
||||
const stat = await this.storage.statExact(resolvedPath);
|
||||
const stat = await this.storage.statResolved(resolvedPath);
|
||||
if (stat?.type !== "file") {
|
||||
this.fileCache.delete(resolvedPath);
|
||||
return null;
|
||||
}
|
||||
|
||||
const file: NodeFile = {
|
||||
path: resolvedPath as FilePath,
|
||||
path: resolvedPath,
|
||||
stat: {
|
||||
size: stat.size,
|
||||
mtime: stat.mtime,
|
||||
@@ -158,10 +179,8 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
|
||||
this.fileCache.set(resolvedPath, file);
|
||||
return file;
|
||||
} catch (error) {
|
||||
if (error instanceof AmbiguousNormalisedPathError) throw error;
|
||||
// Evict so a deleted file is not returned by subsequent cache scans.
|
||||
this.fileCache.delete(resolvedPath);
|
||||
return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,34 +188,83 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
|
||||
* Helper method to recursively scan directory and populate file cache
|
||||
*/
|
||||
async scanDirectory(relativePath: string = ""): Promise<void> {
|
||||
const resolvedPath = await this.storage.resolveLogicalPath(relativePath, false, false);
|
||||
if (resolvedPath !== null) await this.scanDirectoryInto(resolvedPath, this.fileCache);
|
||||
const files = new Map<string, NodeFile>();
|
||||
await this.scanDirectoryInto(relativePath, files);
|
||||
for (const [path, file] of files) this.fileCache.set(path, file);
|
||||
}
|
||||
|
||||
private async scanDirectoryInto(relativePath: string, files: Map<string, NodeFile>): Promise<void> {
|
||||
const resolver = this.createScanResolver();
|
||||
const filePath = parseFilePath(relativePath);
|
||||
if (resolver) {
|
||||
const resolvedPath =
|
||||
filePath === "" ? await resolver.resolveForWrite(filePath) : await resolver.resolveExisting(filePath);
|
||||
if (resolvedPath !== null) await this.scanResolvedDirectoryInto(resolver, resolvedPath, files);
|
||||
return;
|
||||
}
|
||||
await this.scanLegacyDirectoryInto(filePath, files);
|
||||
}
|
||||
|
||||
private createScanResolver(): StoragePathResolver | null {
|
||||
if (!this.storage.isUnicodePathNormalizationEnabled) return null;
|
||||
const caseInsensitive = this.isCaseInsensitiveSetting();
|
||||
return createStoragePathResolver({
|
||||
readDirectory: async (physicalDirectory) => await this.storage.readDirectory(physicalDirectory),
|
||||
policy: {
|
||||
pathNormalizationVersion: 1,
|
||||
handleFilenameCaseSensitive: !caseInsensitive,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async scanResolvedDirectoryInto(
|
||||
resolver: StoragePathResolver,
|
||||
relativePath: StoragePath,
|
||||
files: Map<string, NodeFile>
|
||||
): Promise<void> {
|
||||
const directoryStat = await this.storage.statResolved(relativePath);
|
||||
if (directoryStat === null) return;
|
||||
if (directoryStat.type !== "folder") {
|
||||
throw new Error(`Path is not a directory: ${this.resolvePath(relativePath)}`);
|
||||
}
|
||||
|
||||
const entries = await resolver.listResolvedDirectory(relativePath);
|
||||
if (entries === null) return;
|
||||
for (const entry of entries) {
|
||||
if (entry.kind === "directory") {
|
||||
await this.scanResolvedDirectoryInto(resolver, entry.path, files);
|
||||
continue;
|
||||
}
|
||||
|
||||
const stat = await this.storage.statResolved(entry.path);
|
||||
if (stat?.type !== "file") continue;
|
||||
const file: NodeFile = { path: entry.path, stat };
|
||||
files.set(entry.path, file);
|
||||
}
|
||||
}
|
||||
|
||||
private async scanLegacyDirectoryInto(relativePath: string, files: Map<string, NodeFile>): Promise<void> {
|
||||
const fullPath = this.resolvePath(relativePath);
|
||||
try {
|
||||
const directoryStat = this.storage.isUnicodePathNormalizationEnabled
|
||||
? await this.storage.statExact(relativePath)
|
||||
: await this.storage.stat(relativePath, false);
|
||||
const directoryStat = await this.storage.stat(relativePath, false);
|
||||
if (directoryStat?.type !== "folder") throw new Error(`Directory does not exist: ${fullPath}`);
|
||||
const entries = await this.storage.listExact(relativePath);
|
||||
const entries = await this.storage.list(relativePath, false);
|
||||
|
||||
for (const entryPath of entries.files) {
|
||||
const stat = await this.storage.statExact(entryPath);
|
||||
const stat = await this.storage.stat(entryPath, false);
|
||||
if (stat?.type !== "file") continue;
|
||||
const filePath = parseFilePath(entryPath);
|
||||
const file: NodeFile = {
|
||||
path: entryPath as FilePath,
|
||||
path: filePath,
|
||||
stat,
|
||||
};
|
||||
files.set(entryPath, file);
|
||||
files.set(filePath, file);
|
||||
}
|
||||
for (const entryPath of entries.folders) {
|
||||
await this.scanDirectoryInto(entryPath, files);
|
||||
await this.scanLegacyDirectoryInto(entryPath, files);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof AmbiguousNormalisedPathError) throw error;
|
||||
// Directory doesn't exist or is not readable
|
||||
this.reportDiagnostic(`Error scanning directory ${fullPath}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { fsPromises as fs, NodeStorageAdapter, os, path } from "@vrtmrz/livesync-commonlib/node";
|
||||
import { DEFAULT_SETTINGS, type FilePath } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { parseFilePath, type StoragePath } from "@vrtmrz/livesync-commonlib/storage";
|
||||
import {
|
||||
ServiceRebuilder,
|
||||
type ServiceRebuilderDependencies,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/serviceModules/Rebuilder";
|
||||
import { PathServiceCompat } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectablePathService";
|
||||
import { ServiceContext } from "@vrtmrz/livesync-commonlib/compat/services/base/ServiceBase";
|
||||
import { AmbiguousNormalisedPathError } from "./UnicodeResolvingNodeStorageAdapter";
|
||||
import { NodeFileSystemAdapter } from "./NodeFileSystemAdapter";
|
||||
|
||||
describe("NodeFileSystemAdapter file enumeration", () => {
|
||||
@@ -49,6 +51,17 @@ describe("NodeFileSystemAdapter file enumeration", () => {
|
||||
expect((await adapter.getFiles()).map((file) => file.path).sort()).toEqual(paths);
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"compatibility: lists other files in Legacy mode when one root name is unsupported",
|
||||
async () => {
|
||||
const { directory, adapter } = await createVault();
|
||||
await fs.writeFile(path.join(directory, "a:unrelated.md"), "unrelated local bytes");
|
||||
|
||||
expect((await adapter.getFiles()).map((file) => file.path).sort()).toEqual(paths);
|
||||
expect(await fs.readFile(path.join(directory, "a:unrelated.md"), "utf8")).toBe("unrelated local bytes");
|
||||
}
|
||||
);
|
||||
|
||||
it("excludes a deleted file after its cache entry is refreshed", async () => {
|
||||
const { directory, adapter } = await createVault();
|
||||
await adapter.getFiles();
|
||||
@@ -219,6 +232,120 @@ describe("NodeFileSystemAdapter Unicode path resolution", () => {
|
||||
);
|
||||
}
|
||||
|
||||
it("propagates path-resolution failures when refreshing a file", async () => {
|
||||
const directory = await createVault();
|
||||
const adapter = createNormalisingAdapter(directory);
|
||||
const error = Object.assign(new Error("Storage permission denied"), { code: "EACCES" });
|
||||
vi.spyOn(adapter.storage, "resolveLogicalPath").mockRejectedValue(error);
|
||||
|
||||
await expect(adapter.refreshFile("a.md")).rejects.toBe(error);
|
||||
});
|
||||
|
||||
it("propagates strict metadata failures when refreshing a resolved file", async () => {
|
||||
const directory = await createVault();
|
||||
await fs.writeFile(path.join(directory, "a.md"), "content");
|
||||
const adapter = createNormalisingAdapter(directory);
|
||||
const error = Object.assign(new Error("Storage permission denied"), { code: "EACCES" });
|
||||
vi.spyOn(adapter.storage, "statResolved").mockRejectedValue(error);
|
||||
|
||||
await expect(adapter.getAbstractFileByPath("a.md")).rejects.toBe(error);
|
||||
});
|
||||
|
||||
it("keeps resolved metadata reads strict when the identity mode changes during lookup", async () => {
|
||||
const directory = await createVault();
|
||||
await fs.writeFile(path.join(directory, "a.md"), "content");
|
||||
let version = 1;
|
||||
const adapter = new NodeFileSystemAdapter(directory, undefined, () => version);
|
||||
const error = Object.assign(new Error("Storage permission denied"), { code: "EACCES" });
|
||||
const resolve = adapter.storage.resolveLogicalPath.bind(adapter.storage);
|
||||
vi.spyOn(adapter.storage, "resolveLogicalPath").mockImplementation(async (...args) => {
|
||||
const result = await resolve(...args);
|
||||
version = 0;
|
||||
return result;
|
||||
});
|
||||
vi.spyOn(adapter.storage, "statResolved").mockRejectedValue(error);
|
||||
|
||||
await expect(adapter.getAbstractFileByPath("a.md")).rejects.toBe(error);
|
||||
});
|
||||
|
||||
it("propagates strict directory-read failures without reporting an incomplete scan", async () => {
|
||||
const directory = await createVault();
|
||||
const report = vi.fn();
|
||||
const adapter = new NodeFileSystemAdapter(directory, report, () => 1);
|
||||
const error = Object.assign(new Error("Storage permission denied"), { code: "EACCES" });
|
||||
vi.spyOn(adapter.storage, "readDirectory").mockRejectedValue(error);
|
||||
|
||||
await expect(adapter.getFiles()).rejects.toBe(error);
|
||||
expect(report).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not return or cache a partial NFC listing after a nested directory read fails", async () => {
|
||||
const directory = await createVault();
|
||||
const knownPath = "Known.md";
|
||||
const initialContent = "initial content";
|
||||
await fs.writeFile(path.join(directory, knownPath), initialContent);
|
||||
let normalizationVersion = 1;
|
||||
const adapter = new NodeFileSystemAdapter(directory, undefined, () => normalizationVersion, () => true);
|
||||
await adapter.scanDirectory();
|
||||
|
||||
const changedContent = "changed content after cache";
|
||||
await fs.writeFile(path.join(directory, knownPath), changedContent);
|
||||
await fs.mkdir(path.join(directory, "late-directory"));
|
||||
await fs.writeFile(path.join(directory, "late-directory", "later.md"), "later content");
|
||||
|
||||
const error = Object.assign(new Error("Storage permission denied"), { code: "EACCES" });
|
||||
const readDirectory = adapter.storage.readDirectory.bind(adapter.storage);
|
||||
const readDirectorySpy = vi.spyOn(adapter.storage, "readDirectory").mockImplementation(async (relativePath) => {
|
||||
if (relativePath === "late-directory") throw error;
|
||||
const entries = await readDirectory(relativePath);
|
||||
return relativePath === "" && entries !== null
|
||||
? [...entries].sort((left, right) => (left.name < right.name ? -1 : left.name > right.name ? 1 : 0))
|
||||
: entries;
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(adapter.getFiles()).rejects.toBe(error);
|
||||
await expect(adapter.scanDirectory()).rejects.toBe(error);
|
||||
} finally {
|
||||
readDirectorySpy.mockRestore();
|
||||
}
|
||||
|
||||
// Compatibility-mode case-insensitive lookup exposes the cache without
|
||||
// triggering another scan, so it can verify that the failed NFC scan did
|
||||
// not publish its already-enumerated file metadata.
|
||||
normalizationVersion = 0;
|
||||
const scanDirectory = vi.spyOn(adapter, "scanDirectory");
|
||||
await expect(adapter.getAbstractFileByPathInsensitive("known.md")).resolves.toMatchObject({
|
||||
path: knownPath,
|
||||
stat: { size: initialContent.length },
|
||||
});
|
||||
expect(scanDirectory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses one comparison policy snapshot for the whole NFC scan", async () => {
|
||||
const directory = await createVault();
|
||||
await fs.mkdir(path.join(directory, "Folder"));
|
||||
await fs.writeFile(path.join(directory, "Folder/Note.md"), "upper");
|
||||
await fs.writeFile(path.join(directory, "Folder/note.md"), "lower");
|
||||
let policyReads = 0;
|
||||
let normalizationReads = 0;
|
||||
const adapter = new NodeFileSystemAdapter(
|
||||
directory,
|
||||
undefined,
|
||||
() => {
|
||||
normalizationReads++;
|
||||
return 1;
|
||||
},
|
||||
() => ++policyReads > 1
|
||||
);
|
||||
|
||||
const files = await adapter.getFiles();
|
||||
|
||||
expect(files.map((file) => file.path).sort()).toEqual(["Folder/Note.md", "Folder/note.md"]);
|
||||
expect(normalizationReads).toBe(1);
|
||||
expect(policyReads).toBe(1);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempDirs.splice(0).map((directory) => fs.rm(directory, { recursive: true, force: true })));
|
||||
});
|
||||
@@ -253,13 +380,13 @@ describe("NodeFileSystemAdapter Unicode path resolution", () => {
|
||||
);
|
||||
for (const name of names) await fs.writeFile(path.join(directory, child, name), "content");
|
||||
const adapter = createNormalisingAdapter(directory, caseInsensitive);
|
||||
const list = vi.spyOn(NodeStorageAdapter.prototype, "list");
|
||||
const readDirectory = vi.spyOn(NodeStorageAdapter.prototype, "readDirectory");
|
||||
try {
|
||||
const files = await adapter.getFiles();
|
||||
expect(files.map((file) => file.path).sort()).toEqual(names.map((name) => `${child}/${name}`).sort());
|
||||
expect(list).toHaveBeenCalledTimes(3);
|
||||
expect(readDirectory).toHaveBeenCalledTimes(3);
|
||||
} finally {
|
||||
list.mockRestore();
|
||||
readDirectory.mockRestore();
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -306,12 +433,12 @@ describe("NodeFileSystemAdapter Unicode path resolution", () => {
|
||||
await fs.writeFile(path.join(directory, `${index}.md`), "content");
|
||||
}
|
||||
const adapter = createNormalisingAdapter(directory, caseInsensitive);
|
||||
const list = vi.spyOn(NodeStorageAdapter.prototype, "list");
|
||||
const readDirectory = vi.spyOn(NodeStorageAdapter.prototype, "readDirectory");
|
||||
try {
|
||||
expect(await adapter.getFiles()).toHaveLength(12);
|
||||
expect(list).toHaveBeenCalledTimes(1);
|
||||
expect(readDirectory).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
list.mockRestore();
|
||||
readDirectory.mockRestore();
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -379,11 +506,11 @@ describe("NodeFileSystemAdapter Unicode path resolution", () => {
|
||||
await fs.writeFile(path.join(directory, nfdName), "NFD", "utf8");
|
||||
const adapter = createNormalisingAdapter(directory);
|
||||
const exactFile = {
|
||||
path: nfcName as FilePath,
|
||||
path: parseFilePath(nfcName),
|
||||
stat: { ctime: 1, mtime: 2, size: 3, type: "file" as const },
|
||||
};
|
||||
|
||||
await expect(adapter.getAbstractFileByPath(nfcName)).rejects.toThrow(/ambiguous/i);
|
||||
await expect(adapter.getAbstractFileByPath(nfcName)).rejects.toBeInstanceOf(AmbiguousNormalisedPathError);
|
||||
await expect(adapter.vault.modify(exactFile, "changed")).rejects.toThrow(/ambiguous/i);
|
||||
await expect(adapter.vault.delete(exactFile)).rejects.toThrow(/ambiguous/i);
|
||||
|
||||
@@ -545,7 +672,8 @@ describe("NodeFileSystemAdapter Unicode path resolution", () => {
|
||||
const adapter = createNormalisingAdapter(directory);
|
||||
|
||||
await expect(adapter.getFiles()).resolves.toEqual([]);
|
||||
await expect(adapter.storage.statExact("linked/note.md")).rejects.toThrow(/symbolic links/i);
|
||||
await expect(adapter.storage.resolveLogicalPath(parseFilePath("linked/note.md"))).rejects.toThrow(/symbolic links/i);
|
||||
await expect(adapter.storage.statExact("linked/note.md" as StoragePath)).rejects.toThrow(/symbolic links/i);
|
||||
await expect(adapter.storage.write("linked/note.md", "changed")).rejects.toThrow(/symbolic links/i);
|
||||
await expect(fs.readFile(path.join(outside, "note.md"), "utf8")).resolves.toBe("outside");
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { FilePath, UXDataWriteOptions } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { UXDataWriteOptions } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { IVaultAdapter } from "@vrtmrz/livesync-commonlib/compat/serviceModules/adapters";
|
||||
import type { NodeFile, NodeFolder } from "./NodeTypes";
|
||||
import { NodeStorageAdapter } from "@vrtmrz/livesync-commonlib/node";
|
||||
import { UnicodeResolvingNodeStorageAdapter } from "./UnicodeResolvingNodeStorageAdapter";
|
||||
import { NodeStorageAdapter, UnicodeResolvingNodeStorageAdapter } from "@vrtmrz/livesync-commonlib/node";
|
||||
import { parseFilePath } from "@vrtmrz/livesync-commonlib/storage";
|
||||
|
||||
/**
|
||||
* Vault adapter implementation for Node.js
|
||||
@@ -56,7 +56,7 @@ export class NodeVaultAdapter implements IVaultAdapter<NodeFile> {
|
||||
|
||||
async rename(file: NodeFile, newPath: string): Promise<void> {
|
||||
await this.storage.rename(file.path, newPath);
|
||||
file.path = newPath as FilePath;
|
||||
file.path = parseFilePath(newPath);
|
||||
}
|
||||
|
||||
async delete(file: NodeFile | NodeFolder, force = false): Promise<void> {
|
||||
@@ -74,16 +74,17 @@ export class NodeVaultAdapter implements IVaultAdapter<NodeFile> {
|
||||
}
|
||||
|
||||
private async toNodeFile(path: string): Promise<NodeFile> {
|
||||
const resolvedPath =
|
||||
this.storage instanceof UnicodeResolvingNodeStorageAdapter
|
||||
? await this.storage.resolveLogicalPath(path, undefined, false)
|
||||
: path;
|
||||
if (resolvedPath === null) throw new Error(`Could not read created file metadata: ${path}`);
|
||||
const stat =
|
||||
this.storage instanceof UnicodeResolvingNodeStorageAdapter
|
||||
? await this.storage.statExact(resolvedPath)
|
||||
: await this.storage.stat(resolvedPath);
|
||||
const filePath = parseFilePath(path);
|
||||
if (this.storage instanceof UnicodeResolvingNodeStorageAdapter) {
|
||||
const resolvedPath = await this.storage.resolveLogicalPath(filePath, undefined, false);
|
||||
if (resolvedPath === null) throw new Error(`Could not read created file metadata: ${path}`);
|
||||
const stat = await this.storage.statExact(resolvedPath);
|
||||
if (stat?.type !== "file") throw new Error(`Could not read created file metadata: ${path}`);
|
||||
return { path: resolvedPath, stat };
|
||||
}
|
||||
|
||||
const stat = await this.storage.stat(filePath);
|
||||
if (stat?.type !== "file") throw new Error(`Could not read created file metadata: ${path}`);
|
||||
return { path: resolvedPath as FilePath, stat };
|
||||
return { path: filePath, stat };
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { fsPromises, os, path } from "@vrtmrz/livesync-commonlib/node";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { FilePath } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { parseFilePath } from "@vrtmrz/livesync-commonlib/storage";
|
||||
import { NodeFileSystemAdapter } from "./NodeFileSystemAdapter";
|
||||
import { NodeVaultAdapter } from "./NodeVaultAdapter";
|
||||
|
||||
@@ -11,7 +11,7 @@ describe("NodeVaultAdapter.rename", () => {
|
||||
await fsPromises.writeFile(path.join(directory, "Calculus.md"), "content", "utf8");
|
||||
const adapter = new NodeVaultAdapter(directory);
|
||||
const file = {
|
||||
path: "Calculus.md" as FilePath,
|
||||
path: parseFilePath("Calculus.md"),
|
||||
stat: { ctime: 1, mtime: 2, size: 7, type: "file" as const },
|
||||
};
|
||||
|
||||
@@ -37,7 +37,7 @@ describe("NodeVaultAdapter.rename", () => {
|
||||
);
|
||||
const adapter = new NodeVaultAdapter(directory);
|
||||
const file = {
|
||||
path: "source.md" as FilePath,
|
||||
path: parseFilePath("source.md"),
|
||||
stat: { ctime: 1, mtime: 2, size: 7, type: "file" as const },
|
||||
};
|
||||
|
||||
@@ -65,7 +65,7 @@ describe("NodeVaultAdapter.rename", () => {
|
||||
);
|
||||
const adapter = new NodeVaultAdapter(directory);
|
||||
const file = {
|
||||
path: "linked/victim.md" as FilePath,
|
||||
path: parseFilePath("linked/victim.md"),
|
||||
stat: { ctime: 1, mtime: 2, size: 6, type: "file" as const },
|
||||
};
|
||||
|
||||
|
||||
@@ -1,205 +1,2 @@
|
||||
import { NodeStorageAdapter, validateStoragePath } from "@vrtmrz/livesync-commonlib/node";
|
||||
|
||||
export class AmbiguousNormalisedPathError extends Error {
|
||||
constructor(path: string) {
|
||||
super(`Ambiguous path after Unicode normalisation: ${path}`);
|
||||
this.name = "AmbiguousNormalisedPathError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolves NFC-equivalent logical paths against current Node storage listings. */
|
||||
export class UnicodeResolvingNodeStorageAdapter extends NodeStorageAdapter {
|
||||
constructor(
|
||||
rootPath: string,
|
||||
private readonly getPathNormalizationVersion: () => number = () => 0,
|
||||
private readonly isCaseInsensitive: () => boolean = () => false
|
||||
) {
|
||||
super(rootPath);
|
||||
}
|
||||
|
||||
get isUnicodePathNormalizationEnabled(): boolean {
|
||||
return this.getPathNormalizationVersion() === 1;
|
||||
}
|
||||
|
||||
/** Read metadata for an entry already resolved or obtained from a storage listing. */
|
||||
async statExact(path: string) {
|
||||
return await super.trystat(path);
|
||||
}
|
||||
|
||||
/** Inspect a physical directory once before descending into its enumerated entries. */
|
||||
async listExact(path: string) {
|
||||
const entries = await super.list(path);
|
||||
if (this.isUnicodePathNormalizationEnabled) {
|
||||
const caseInsensitive = this.isCaseInsensitive();
|
||||
const names = new Map<string, string>();
|
||||
for (const entry of [...entries.files, ...entries.folders]) {
|
||||
const key = this.comparisonKey(entry, caseInsensitive);
|
||||
if (names.has(key)) throw new AmbiguousNormalisedPathError(entry);
|
||||
names.set(key, entry);
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
async resolveLogicalPath(
|
||||
path: string,
|
||||
caseInsensitive = this.isCaseInsensitive(),
|
||||
allowMissing = true
|
||||
): Promise<string | null> {
|
||||
if (!this.isUnicodePathNormalizationEnabled) return path;
|
||||
|
||||
validateStoragePath(path, true);
|
||||
if (path === "") return "";
|
||||
|
||||
const segments = path.split("/");
|
||||
let resolvedParent = "";
|
||||
|
||||
for (let index = 0; index < segments.length; index++) {
|
||||
const segment = segments[index];
|
||||
const entries = await super.list(resolvedParent);
|
||||
const candidates = [...entries.files, ...entries.folders];
|
||||
const requestedKey = this.comparisonKey(segment, caseInsensitive);
|
||||
const matches = candidates.filter((entryPath) => {
|
||||
const entryName = entryPath.slice(resolvedParent === "" ? 0 : resolvedParent.length + 1);
|
||||
return this.comparisonKey(entryName, caseInsensitive) === requestedKey;
|
||||
});
|
||||
|
||||
if (matches.length > 1) throw new AmbiguousNormalisedPathError(path);
|
||||
if (matches.length === 1) {
|
||||
resolvedParent = matches[0];
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!allowMissing) return null;
|
||||
const remainingPath = segments.slice(index).join("/");
|
||||
return resolvedParent === "" ? remainingPath : `${resolvedParent}/${remainingPath}`;
|
||||
}
|
||||
|
||||
return resolvedParent;
|
||||
}
|
||||
|
||||
override async exists(path: string, caseInsensitive = this.isCaseInsensitive()): Promise<boolean> {
|
||||
const resolvedPath = await this.resolveLogicalPath(path, caseInsensitive, false);
|
||||
if (resolvedPath === null) return false;
|
||||
return await super.exists(resolvedPath);
|
||||
}
|
||||
|
||||
override async trystat(path: string, caseInsensitive = this.isCaseInsensitive()) {
|
||||
const resolvedPath = await this.resolveLogicalPath(path, caseInsensitive, false);
|
||||
if (resolvedPath === null) return null;
|
||||
return await super.trystat(resolvedPath);
|
||||
}
|
||||
|
||||
override async stat(path: string, caseInsensitive = this.isCaseInsensitive()) {
|
||||
const resolvedPath = await this.resolveLogicalPath(path, caseInsensitive, false);
|
||||
if (resolvedPath === null) return null;
|
||||
return await super.trystat(resolvedPath);
|
||||
}
|
||||
|
||||
override async mkdir(path: string, caseInsensitive = this.isCaseInsensitive()): Promise<void> {
|
||||
const resolvedPath = await this.resolveLogicalPath(path, caseInsensitive, true);
|
||||
if (resolvedPath === null) throw new Error(`Could not resolve path: ${path}`);
|
||||
await super.mkdir(resolvedPath);
|
||||
}
|
||||
|
||||
override async remove(path: string, caseInsensitive = this.isCaseInsensitive()): Promise<void> {
|
||||
const resolvedPath = await this.resolveLogicalPath(path, caseInsensitive, false);
|
||||
await super.remove(resolvedPath ?? path);
|
||||
}
|
||||
|
||||
override async read(path: string, caseInsensitive = this.isCaseInsensitive()): Promise<string> {
|
||||
const resolvedPath = await this.resolveLogicalPath(path, caseInsensitive, false);
|
||||
return await super.read(resolvedPath ?? path);
|
||||
}
|
||||
|
||||
override async readBinary(path: string, caseInsensitive = this.isCaseInsensitive()): Promise<ArrayBuffer> {
|
||||
const resolvedPath = await this.resolveLogicalPath(path, caseInsensitive, false);
|
||||
return await super.readBinary(resolvedPath ?? path);
|
||||
}
|
||||
|
||||
override async write(
|
||||
path: string,
|
||||
data: string,
|
||||
options?: Parameters<NodeStorageAdapter["write"]>[2],
|
||||
caseInsensitive = this.isCaseInsensitive()
|
||||
): Promise<void> {
|
||||
const resolvedPath = await this.resolveLogicalPath(path, caseInsensitive, true);
|
||||
if (resolvedPath === null) throw new Error(`Could not resolve path: ${path}`);
|
||||
await super.write(resolvedPath, data, options);
|
||||
}
|
||||
|
||||
override async writeBinary(
|
||||
path: string,
|
||||
data: ArrayBuffer,
|
||||
options?: Parameters<NodeStorageAdapter["writeBinary"]>[2],
|
||||
caseInsensitive = this.isCaseInsensitive()
|
||||
): Promise<void> {
|
||||
const resolvedPath = await this.resolveLogicalPath(path, caseInsensitive, true);
|
||||
if (resolvedPath === null) throw new Error(`Could not resolve path: ${path}`);
|
||||
await super.writeBinary(resolvedPath, data, options);
|
||||
}
|
||||
|
||||
override async append(
|
||||
path: string,
|
||||
data: string,
|
||||
options?: Parameters<NodeStorageAdapter["append"]>[2],
|
||||
caseInsensitive = this.isCaseInsensitive()
|
||||
): Promise<void> {
|
||||
const resolvedPath = await this.resolveLogicalPath(path, caseInsensitive, true);
|
||||
if (resolvedPath === null) throw new Error(`Could not resolve path: ${path}`);
|
||||
await super.append(resolvedPath, data, options);
|
||||
}
|
||||
|
||||
override async rename(
|
||||
sourcePath: string,
|
||||
targetPath: string,
|
||||
caseInsensitive = this.isCaseInsensitive()
|
||||
): Promise<void> {
|
||||
const resolvedSource = await this.resolveLogicalPath(sourcePath, caseInsensitive, false);
|
||||
if (resolvedSource === null) return await super.rename(sourcePath, targetPath);
|
||||
|
||||
const resolvedTarget = await this.resolveLogicalPath(targetPath, caseInsensitive, true);
|
||||
if (resolvedTarget === null) throw new Error(`Could not resolve path: ${targetPath}`);
|
||||
|
||||
// Preserve explicit case-only or Unicode-spelling renames when the target resolves to the source.
|
||||
const sourceKey = this.comparisonKey(resolvedSource, caseInsensitive);
|
||||
const targetKey = this.comparisonKey(resolvedTarget, caseInsensitive);
|
||||
const target =
|
||||
sourceKey === targetKey && resolvedTarget === resolvedSource && targetPath !== resolvedSource
|
||||
? await this.resolveRenameTargetSpelling(targetPath, caseInsensitive)
|
||||
: resolvedTarget;
|
||||
|
||||
await super.rename(resolvedSource, target);
|
||||
}
|
||||
|
||||
override async list(basePath: string, caseInsensitive = this.isCaseInsensitive()) {
|
||||
const resolvedPath = await this.resolveLogicalPath(basePath, caseInsensitive, false);
|
||||
if (resolvedPath === null) return { files: [], folders: [] };
|
||||
return await super.list(resolvedPath);
|
||||
}
|
||||
|
||||
private async resolveRenameTargetSpelling(path: string, caseInsensitive: boolean): Promise<string> {
|
||||
validateStoragePath(path, false);
|
||||
const segments = path.split("/");
|
||||
const leaf = segments.pop();
|
||||
if (leaf === undefined) throw new Error(`Could not resolve path: ${path}`);
|
||||
const parentPath = segments.join("/");
|
||||
const resolvedParent = await this.resolveLogicalPath(parentPath, caseInsensitive, true);
|
||||
if (resolvedParent === null) throw new Error(`Could not resolve path: ${path}`);
|
||||
|
||||
const entries = await super.list(resolvedParent);
|
||||
const requestedKey = this.comparisonKey(leaf, caseInsensitive);
|
||||
const matches = [...entries.files, ...entries.folders].filter((entryPath) => {
|
||||
const entryName = entryPath.slice(resolvedParent === "" ? 0 : resolvedParent.length + 1);
|
||||
return this.comparisonKey(entryName, caseInsensitive) === requestedKey;
|
||||
});
|
||||
if (matches.length > 1) throw new AmbiguousNormalisedPathError(path);
|
||||
|
||||
return resolvedParent === "" ? leaf : `${resolvedParent}/${leaf}`;
|
||||
}
|
||||
|
||||
private comparisonKey(segment: string, caseInsensitive: boolean): string {
|
||||
const normalisedSegment = segment.normalize("NFC");
|
||||
return caseInsensitive ? normalisedSegment.toLowerCase() : normalisedSegment;
|
||||
}
|
||||
}
|
||||
export { UnicodeResolvingNodeStorageAdapter } from "@vrtmrz/livesync-commonlib/node";
|
||||
export { AmbiguousNormalisedPathError } from "@vrtmrz/livesync-commonlib/storage";
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { StandardIo } from "@vrtmrz/livesync-commonlib/context";
|
||||
import {
|
||||
isReplicationCompleted,
|
||||
REPLICATION_PROGRESS_PRESENTATIONS,
|
||||
USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
} from "@vrtmrz/livesync-commonlib/replication";
|
||||
import { isLiveSyncJournalReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator";
|
||||
import { writeStderrLine, writeStdoutLine } from "@/apps/cli/cliOutput";
|
||||
import type { CLICommandContext, CLIOptions } from "./types";
|
||||
|
||||
type JournalSkipOptions = Pick<CLIOptions, "commandArgs">;
|
||||
|
||||
type JournalSkipContext = {
|
||||
standardIo: StandardIo;
|
||||
getActiveReplicator: CLICommandContext["core"]["services"]["replicator"]["getActiveReplicator"];
|
||||
replicateUserInitiated: CLICommandContext["core"]["services"]["replication"]["replicateUserInitiated"];
|
||||
currentSettings: CLICommandContext["core"]["services"]["setting"]["currentSettings"];
|
||||
};
|
||||
|
||||
/** Retry replication, then skip only the exact Journal snapshot left by the same replicator. */
|
||||
export async function runJournalSkipCommand(
|
||||
options: JournalSkipOptions,
|
||||
context: JournalSkipContext
|
||||
): Promise<boolean> {
|
||||
const { standardIo } = context;
|
||||
const [journalKey] = options.commandArgs;
|
||||
if (options.commandArgs.length !== 1 || journalKey === undefined || journalKey.length === 0) {
|
||||
writeStderrLine(standardIo, `[Error] journal-skip requires one argument: <journal-key>`);
|
||||
return false;
|
||||
}
|
||||
|
||||
writeStdoutLine(standardIo, `[Command] journal-skip ${journalKey}`);
|
||||
const replicatorBeforeReplication = context.getActiveReplicator();
|
||||
const result = await context.replicateUserInitiated({
|
||||
trigger: "manual",
|
||||
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.NOTICE,
|
||||
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
});
|
||||
const activeReplicator = context.getActiveReplicator();
|
||||
|
||||
if (activeReplicator !== replicatorBeforeReplication) {
|
||||
writeStderrLine(
|
||||
standardIo,
|
||||
`[Error] The active replicator changed during replication; no Journal was skipped.`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (isReplicationCompleted(result)) {
|
||||
writeStderrLine(standardIo, `[Error] Replication completed without a stopped Journal; no Journal was skipped.`);
|
||||
return false;
|
||||
}
|
||||
if (!isLiveSyncJournalReplicator(activeReplicator)) {
|
||||
writeStderrLine(
|
||||
standardIo,
|
||||
`[Error] The active replicator is not a Journal replicator; no Journal was skipped.`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const stoppedJournal = activeReplicator.getStoppedJournal();
|
||||
if (!stoppedJournal) {
|
||||
writeStderrLine(standardIo, `[Error] Replication did not stop at a Journal; no Journal was skipped.`);
|
||||
return false;
|
||||
}
|
||||
if (stoppedJournal.key !== journalKey) {
|
||||
writeStderrLine(
|
||||
standardIo,
|
||||
`[Error] Replication stopped at Journal '${stoppedJournal.key}' (${stoppedJournal.stage}), not '${journalKey}'; no Journal was skipped.`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
return await activeReplicator.skipStoppedJournalAndResume(context.currentSettings(), stoppedJournal, true);
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { LiveSyncJournalReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator";
|
||||
import {
|
||||
REPLICATION_COMPLETED,
|
||||
REPLICATION_PROGRESS_PRESENTATIONS,
|
||||
USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
replicationFailed,
|
||||
} from "@vrtmrz/livesync-commonlib/replication";
|
||||
import { runJournalSkipCommand } from "./journalSkip";
|
||||
|
||||
const snapshot = { key: "7-docs.jsonl.gz", stage: "parse" } as const;
|
||||
const settings = {} as any;
|
||||
type ReplicationResult = Awaited<ReturnType<Parameters<typeof runJournalSkipCommand>[1]["replicateUserInitiated"]>>;
|
||||
|
||||
function createStandardIoMock(events?: string[]) {
|
||||
return {
|
||||
readStdin: vi.fn(async () => ""),
|
||||
prompt: vi.fn(async () => ""),
|
||||
writeStdout: vi.fn((_chunk: string | Uint8Array) => events?.push("stdout")),
|
||||
writeStderr: vi.fn((_chunk: string | Uint8Array) => events?.push("stderr")),
|
||||
} as any;
|
||||
}
|
||||
|
||||
function createJournalReplicatorMock(stoppedJournal: unknown, events?: string[]) {
|
||||
const replicator = Object.create(LiveSyncJournalReplicator.prototype) as any;
|
||||
replicator.getStoppedJournal = vi.fn(() => {
|
||||
events?.push("getStoppedJournal");
|
||||
return stoppedJournal;
|
||||
});
|
||||
replicator.skipStoppedJournalAndResume = vi.fn(async () => {
|
||||
events?.push("skipStoppedJournalAndResume");
|
||||
return true;
|
||||
});
|
||||
return replicator;
|
||||
}
|
||||
|
||||
function createDependencies(replicator: any, result?: ReplicationResult, events?: string[]) {
|
||||
const standardIo = createStandardIoMock(events);
|
||||
return {
|
||||
standardIo,
|
||||
getActiveReplicator: vi.fn(() => {
|
||||
events?.push("getActiveReplicator");
|
||||
return replicator;
|
||||
}),
|
||||
replicateUserInitiated: vi.fn(async () => {
|
||||
events?.push("replicateUserInitiated");
|
||||
return result ?? replicationFailed(new Error("stopped Journal"));
|
||||
}),
|
||||
currentSettings: vi.fn(() => {
|
||||
events?.push("currentSettings");
|
||||
return settings;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function makeOptions(commandArgs: string[]) {
|
||||
return { commandArgs };
|
||||
}
|
||||
|
||||
describe("journal-skip command handler", () => {
|
||||
it("retries replication, then skips the exact stopped snapshot in order", async () => {
|
||||
const events: string[] = [];
|
||||
const replicator = createJournalReplicatorMock(snapshot, events);
|
||||
const dependencies = createDependencies(replicator, undefined, events);
|
||||
|
||||
await expect(runJournalSkipCommand(makeOptions([snapshot.key]), dependencies)).resolves.toBe(true);
|
||||
|
||||
expect(dependencies.replicateUserInitiated).toHaveBeenCalledWith({
|
||||
trigger: "manual",
|
||||
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.NOTICE,
|
||||
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
});
|
||||
expect(dependencies.getActiveReplicator).toHaveBeenCalledTimes(2);
|
||||
expect(replicator.getStoppedJournal).toHaveBeenCalledOnce();
|
||||
expect(replicator.skipStoppedJournalAndResume).toHaveBeenCalledWith(settings, snapshot, true);
|
||||
expect(replicator.skipStoppedJournalAndResume.mock.calls[0][1]).toBe(snapshot);
|
||||
expect(events).toEqual([
|
||||
"stdout",
|
||||
"getActiveReplicator",
|
||||
"replicateUserInitiated",
|
||||
"getActiveReplicator",
|
||||
"getStoppedJournal",
|
||||
"currentSettings",
|
||||
"skipStoppedJournalAndResume",
|
||||
]);
|
||||
expect(dependencies.standardIo.writeStdout).toHaveBeenCalledWith(`[Command] journal-skip ${snapshot.key}\n`);
|
||||
expect(dependencies.standardIo.writeStderr).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ args: [], description: "no argument" },
|
||||
{ args: [""], description: "an empty key" },
|
||||
{ args: [snapshot.key, "extra"], description: "more than one argument" },
|
||||
])("rejects $description before replication", async ({ args }) => {
|
||||
const replicator = createJournalReplicatorMock(snapshot);
|
||||
const dependencies = createDependencies(replicator);
|
||||
|
||||
await expect(runJournalSkipCommand(makeOptions(args), dependencies)).resolves.toBe(false);
|
||||
|
||||
expect(dependencies.standardIo.writeStdout).not.toHaveBeenCalled();
|
||||
expect(dependencies.standardIo.writeStderr).toHaveBeenCalledWith(
|
||||
"[Error] journal-skip requires one argument: <journal-key>\n"
|
||||
);
|
||||
expect(dependencies.getActiveReplicator).not.toHaveBeenCalled();
|
||||
expect(dependencies.replicateUserInitiated).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses a completed replication before reading a stopped snapshot", async () => {
|
||||
const replicator = createJournalReplicatorMock(snapshot);
|
||||
const dependencies = createDependencies(replicator, REPLICATION_COMPLETED);
|
||||
|
||||
await expect(runJournalSkipCommand(makeOptions([snapshot.key]), dependencies)).resolves.toBe(false);
|
||||
|
||||
expect(replicator.getStoppedJournal).not.toHaveBeenCalled();
|
||||
expect(replicator.skipStoppedJournalAndResume).not.toHaveBeenCalled();
|
||||
expect(dependencies.standardIo.writeStderr).toHaveBeenCalledWith(
|
||||
"[Error] Replication completed without a stopped Journal; no Journal was skipped.\n"
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ status: "failed", error: new Error("stopped") },
|
||||
{ status: "cancelled" },
|
||||
{ status: "blocked", reason: "not-ready" },
|
||||
{ status: "partial", detail: "incomplete" },
|
||||
] satisfies ReplicationResult[])("requires a stopped snapshot after a $status outcome", async (result) => {
|
||||
const replicator = createJournalReplicatorMock(undefined);
|
||||
const dependencies = createDependencies(replicator, result);
|
||||
|
||||
await expect(runJournalSkipCommand(makeOptions([snapshot.key]), dependencies)).resolves.toBe(false);
|
||||
|
||||
expect(replicator.getStoppedJournal).toHaveBeenCalledOnce();
|
||||
expect(replicator.skipStoppedJournalAndResume).not.toHaveBeenCalled();
|
||||
expect(dependencies.standardIo.writeStderr).toHaveBeenCalledWith(
|
||||
"[Error] Replication did not stop at a Journal; no Journal was skipped.\n"
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses an active replicator which is not a Journal replicator", async () => {
|
||||
const otherReplicator = { getStoppedJournal: vi.fn(() => snapshot) };
|
||||
const dependencies = createDependencies(otherReplicator);
|
||||
|
||||
await expect(runJournalSkipCommand(makeOptions([snapshot.key]), dependencies)).resolves.toBe(false);
|
||||
|
||||
expect(otherReplicator.getStoppedJournal).not.toHaveBeenCalled();
|
||||
expect(dependencies.standardIo.writeStderr).toHaveBeenCalledWith(
|
||||
"[Error] The active replicator is not a Journal replicator; no Journal was skipped.\n"
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses if the active replicator changes while replication is awaited", async () => {
|
||||
const replicator = createJournalReplicatorMock(snapshot);
|
||||
const replacement = createJournalReplicatorMock(snapshot);
|
||||
const dependencies = createDependencies(replicator);
|
||||
dependencies.getActiveReplicator.mockReturnValueOnce(replicator).mockReturnValueOnce(replacement);
|
||||
|
||||
await expect(runJournalSkipCommand(makeOptions([snapshot.key]), dependencies)).resolves.toBe(false);
|
||||
|
||||
expect(replicator.getStoppedJournal).not.toHaveBeenCalled();
|
||||
expect(replicator.skipStoppedJournalAndResume).not.toHaveBeenCalled();
|
||||
expect(replacement.skipStoppedJournalAndResume).not.toHaveBeenCalled();
|
||||
expect(dependencies.standardIo.writeStderr).toHaveBeenCalledWith(
|
||||
"[Error] The active replicator changed during replication; no Journal was skipped.\n"
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a stopped Journal whose key does not exactly match the requested key", async () => {
|
||||
const replicator = createJournalReplicatorMock(snapshot);
|
||||
const dependencies = createDependencies(replicator);
|
||||
|
||||
await expect(runJournalSkipCommand(makeOptions(["8-docs.jsonl.gz"]), dependencies)).resolves.toBe(false);
|
||||
|
||||
expect(replicator.skipStoppedJournalAndResume).not.toHaveBeenCalled();
|
||||
expect(dependencies.standardIo.writeStderr).toHaveBeenCalledWith(
|
||||
"[Error] Replication stopped at Journal '7-docs.jsonl.gz' (parse), not '8-docs.jsonl.gz'; no Journal was skipped.\n"
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false when resuming after a successful Journal skip returns false", async () => {
|
||||
const replicator = createJournalReplicatorMock(snapshot);
|
||||
replicator.skipStoppedJournalAndResume.mockResolvedValue(false);
|
||||
const dependencies = createDependencies(replicator);
|
||||
|
||||
await expect(runJournalSkipCommand(makeOptions([snapshot.key]), dependencies)).resolves.toBe(false);
|
||||
|
||||
expect(replicator.skipStoppedJournalAndResume).toHaveBeenCalledOnce();
|
||||
expect(dependencies.standardIo.writeStderr).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("propagates rejected replication", async () => {
|
||||
const replicator = createJournalReplicatorMock(snapshot);
|
||||
const dependencies = createDependencies(replicator);
|
||||
const error = new Error("replication rejected");
|
||||
dependencies.replicateUserInitiated.mockRejectedValue(error);
|
||||
|
||||
await expect(runJournalSkipCommand(makeOptions([snapshot.key]), dependencies)).rejects.toBe(error);
|
||||
|
||||
expect(dependencies.getActiveReplicator).toHaveBeenCalledOnce();
|
||||
expect(dependencies.standardIo.writeStderr).not.toHaveBeenCalled();
|
||||
expect(replicator.skipStoppedJournalAndResume).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("propagates a rejected skip and resume operation", async () => {
|
||||
const replicator = createJournalReplicatorMock(snapshot);
|
||||
const dependencies = createDependencies(replicator);
|
||||
const error = new Error("skip rejected");
|
||||
replicator.skipStoppedJournalAndResume.mockRejectedValue(error);
|
||||
|
||||
await expect(runJournalSkipCommand(makeOptions([snapshot.key]), dependencies)).rejects.toBe(error);
|
||||
|
||||
expect(replicator.skipStoppedJournalAndResume).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
} from "@vrtmrz/livesync-commonlib/replication";
|
||||
import { withOwnedRemoteResource } from "@/common/ownedRemoteResource";
|
||||
import { runJournalSkipCommand } from "./journalSkip";
|
||||
import {
|
||||
isCentralRemoteAdministrationCommand,
|
||||
runCentralRemoteAdministrationCommand,
|
||||
@@ -45,10 +46,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
|
||||
writeStderrLine(standardIo, `[Error] ${forceRequirementError}`);
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
options.command === "fetch" &&
|
||||
isRemediationModeActive(core.services.setting.currentSettings())
|
||||
) {
|
||||
if (options.command === "fetch" && isRemediationModeActive(core.services.setting.currentSettings())) {
|
||||
writeStderrLine(
|
||||
standardIo,
|
||||
`[Error] The fetch command cannot run while remediation mode is active. Clear the modification-time limit before fetching.`
|
||||
@@ -202,6 +200,16 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
|
||||
return isReplicationCompleted(result);
|
||||
}
|
||||
|
||||
if (options.command === "journal-skip") {
|
||||
return await runJournalSkipCommand(options, {
|
||||
standardIo,
|
||||
getActiveReplicator: () => core.services.replicator.getActiveReplicator(),
|
||||
replicateUserInitiated: (replicationOptions) =>
|
||||
core.services.replication.replicateUserInitiated(replicationOptions),
|
||||
currentSettings: () => core.services.setting.currentSettings(),
|
||||
});
|
||||
}
|
||||
|
||||
if (options.command === "p2p-peers") {
|
||||
if (options.commandArgs.length < 1) {
|
||||
throw new Error("p2p-peers requires one argument: <timeout>");
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { runCommand } from "./runCommand";
|
||||
import type { CLIOptions } from "./types";
|
||||
import { LiveSyncJournalReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator";
|
||||
import {
|
||||
CENTRAL_REMOTE_ADMINISTRATION_ACTIONS,
|
||||
CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS,
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
CENTRAL_COMPATIBILITY_REJECTION_REASONS,
|
||||
REPLICATION_COMPLETED,
|
||||
REPLICATION_PROGRESS_PRESENTATIONS,
|
||||
USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
replicationFailed,
|
||||
} from "@vrtmrz/livesync-commonlib/replication";
|
||||
|
||||
@@ -137,6 +139,17 @@ function makeOptions(command: CLIOptions["command"], commandArgs: string[]): CLI
|
||||
};
|
||||
}
|
||||
|
||||
function makeJournalSkipOptions(journalKey: string): CLIOptions {
|
||||
return makeOptions("journal-skip", [journalKey]);
|
||||
}
|
||||
|
||||
function createJournalReplicatorMock(stoppedJournal: unknown) {
|
||||
const replicator = Object.create(LiveSyncJournalReplicator.prototype) as any;
|
||||
replicator.getStoppedJournal = vi.fn(() => stoppedJournal);
|
||||
replicator.skipStoppedJournalAndResume = vi.fn(async () => true);
|
||||
return replicator;
|
||||
}
|
||||
|
||||
async function createSetupURI(passphrase: string): Promise<string> {
|
||||
const settings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
@@ -354,6 +367,108 @@ describe("runCommand abnormal cases", () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe("journal-skip command", () => {
|
||||
const snapshot = { key: "7-docs.jsonl.gz", stage: "parse" } as const;
|
||||
|
||||
it("skips only the exact stopped Journal after a normal user-initiated replication", async () => {
|
||||
const core = createCoreMock();
|
||||
const replicator = createJournalReplicatorMock(snapshot);
|
||||
core.services.replicator.getActiveReplicator.mockReturnValue(replicator);
|
||||
core.services.replication.replicateUserInitiated.mockResolvedValue(
|
||||
replicationFailed(new Error("stopped Journal"))
|
||||
);
|
||||
|
||||
await expect(runCommand(makeJournalSkipOptions(snapshot.key), { ...context, core })).resolves.toBe(true);
|
||||
|
||||
expect(core.services.replication.replicateUserInitiated).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
trigger: "manual",
|
||||
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.NOTICE,
|
||||
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
})
|
||||
);
|
||||
expect(replicator.getStoppedJournal).toHaveBeenCalledOnce();
|
||||
expect(replicator.skipStoppedJournalAndResume).toHaveBeenCalledWith(
|
||||
core.services.setting.currentSettings(),
|
||||
snapshot,
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a key which does not match the stopped Journal", async () => {
|
||||
const core = createCoreMock();
|
||||
const replicator = createJournalReplicatorMock(snapshot);
|
||||
core.services.replicator.getActiveReplicator.mockReturnValue(replicator);
|
||||
core.services.replication.replicateUserInitiated.mockResolvedValue(
|
||||
replicationFailed(new Error("stopped Journal"))
|
||||
);
|
||||
|
||||
await expect(runCommand(makeJournalSkipOptions("8-docs.jsonl.gz"), { ...context, core })).resolves.toBe(
|
||||
false
|
||||
);
|
||||
|
||||
expect(replicator.skipStoppedJournalAndResume).not.toHaveBeenCalled();
|
||||
expect(core.services.context.standardIo.writeStderr).toHaveBeenCalledWith(
|
||||
expect.stringContaining("7-docs.jsonl.gz")
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses when normal replication does not establish a stopped Journal", async () => {
|
||||
const core = createCoreMock();
|
||||
const replicator = createJournalReplicatorMock(undefined);
|
||||
core.services.replicator.getActiveReplicator.mockReturnValue(replicator);
|
||||
|
||||
await expect(runCommand(makeJournalSkipOptions(snapshot.key), { ...context, core })).resolves.toBe(false);
|
||||
|
||||
expect(replicator.skipStoppedJournalAndResume).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses when the active replicator is not a Journal replicator", async () => {
|
||||
const core = createCoreMock();
|
||||
core.services.replicator.getActiveReplicator.mockReturnValue({ getStoppedJournal: vi.fn(() => snapshot) });
|
||||
core.services.replication.replicateUserInitiated.mockResolvedValue(
|
||||
replicationFailed(new Error("stopped Journal"))
|
||||
);
|
||||
|
||||
await expect(runCommand(makeJournalSkipOptions(snapshot.key), { ...context, core })).resolves.toBe(false);
|
||||
|
||||
expect(core.services.context.standardIo.writeStderr).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Journal replicator")
|
||||
);
|
||||
});
|
||||
|
||||
it("propagates a failure while resuming after the requested Journal was skipped", async () => {
|
||||
const core = createCoreMock();
|
||||
const replicator = createJournalReplicatorMock(snapshot);
|
||||
replicator.skipStoppedJournalAndResume.mockResolvedValue(false);
|
||||
core.services.replicator.getActiveReplicator.mockReturnValue(replicator);
|
||||
core.services.replication.replicateUserInitiated.mockResolvedValue(
|
||||
replicationFailed(new Error("stopped Journal"))
|
||||
);
|
||||
|
||||
await expect(runCommand(makeJournalSkipOptions(snapshot.key), { ...context, core })).resolves.toBe(false);
|
||||
|
||||
expect(replicator.skipStoppedJournalAndResume).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("refuses if the active replicator changes during the awaited replication", async () => {
|
||||
const core = createCoreMock();
|
||||
const replicator = createJournalReplicatorMock(snapshot);
|
||||
const replacement = createJournalReplicatorMock(snapshot);
|
||||
core.services.replicator.getActiveReplicator
|
||||
.mockReturnValueOnce(replicator)
|
||||
.mockReturnValueOnce(replacement);
|
||||
core.services.replication.replicateUserInitiated.mockResolvedValue(
|
||||
replicationFailed(new Error("stopped Journal"))
|
||||
);
|
||||
|
||||
await expect(runCommand(makeJournalSkipOptions(snapshot.key), { ...context, core })).resolves.toBe(false);
|
||||
|
||||
expect(replicator.skipStoppedJournalAndResume).not.toHaveBeenCalled();
|
||||
expect(replacement.skipStoppedJournalAndResume).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("reports a lock from the exact sync outcome without inspecting a replacement Replicator", async () => {
|
||||
const core = createCoreMock();
|
||||
core.services.replication.replicateUserInitiated.mockResolvedValue(
|
||||
@@ -546,9 +661,9 @@ describe("runCommand abnormal cases", () => {
|
||||
clock.mockReturnValue(end);
|
||||
const outOfWindow = createCoreMock();
|
||||
outOfWindow.services.context.standardIo.prompt.mockResolvedValue(passphrase);
|
||||
await expect(runCommand(makeOptions("setup", [uri.trim()]), { ...context, core: outOfWindow })).rejects.toThrow(
|
||||
"Cannot open Setup URI"
|
||||
);
|
||||
await expect(
|
||||
runCommand(makeOptions("setup", [uri.trim()]), { ...context, core: outOfWindow })
|
||||
).rejects.toThrow("Cannot open Setup URI");
|
||||
expect(outOfWindow.services.setting.applyExternalSettings).not.toHaveBeenCalled();
|
||||
expect(outOfWindow.services.control.applySettings).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
@@ -559,8 +674,12 @@ describe("runCommand abnormal cases", () => {
|
||||
it("setup imports managed TURN through the existing encrypted URI", async () => {
|
||||
const core = createCoreMock();
|
||||
const profiles = {
|
||||
turn: { id: "turn", name: "TURN", isEncrypted: false,
|
||||
uri: "sls+p2p://room?managedType=CF&managedId=turn-key&token=private-token" },
|
||||
turn: {
|
||||
id: "turn",
|
||||
name: "TURN",
|
||||
isEncrypted: false,
|
||||
uri: "sls+p2p://room?managedType=CF&managedId=turn-key&token=private-token",
|
||||
},
|
||||
};
|
||||
const passphrase = "setup-passphrase";
|
||||
const setupURI = await processSetting.encodeSettingsToSetupURI(
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { ReplicationSchedulingControl } from "@/serviceFeatures/replication
|
||||
export type CLICommand =
|
||||
| "daemon"
|
||||
| "sync"
|
||||
| "journal-skip"
|
||||
| "p2p-peers"
|
||||
| "p2p-sync"
|
||||
| "p2p-host"
|
||||
@@ -87,6 +88,7 @@ export interface CLICommandContext {
|
||||
export const VALID_COMMANDS = new Set([
|
||||
"daemon",
|
||||
"sync",
|
||||
"journal-skip",
|
||||
"p2p-peers",
|
||||
"p2p-sync",
|
||||
"p2p-host",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -2,6 +2,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { LOG_LEVEL } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import {
|
||||
Logger,
|
||||
LOG_LEVEL_DEBUG,
|
||||
LOG_LEVEL_INFO,
|
||||
LOG_LEVEL_NOTICE,
|
||||
LOG_LEVEL_URGENT,
|
||||
LOG_LEVEL_VERBOSE,
|
||||
setGlobalLogFunction,
|
||||
} from "octagonal-wheels/common/logger";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
setLogHandler: vi.fn(),
|
||||
@@ -29,6 +39,18 @@ function createStandardIoMock() {
|
||||
};
|
||||
}
|
||||
|
||||
async function invokeGlobalLogger(databasePath: string, message: string, level: LOG_LEVEL, debug = false) {
|
||||
process.argv = ["node", "livesync-cli", databasePath, ...(debug ? ["--debug"] : []), "remote-ls"];
|
||||
const standardIo = createStandardIoMock();
|
||||
mocks.setLogHandler.mockImplementation(() => {
|
||||
Logger(message, level);
|
||||
throw new Error("__LOG_HANDLER_CONFIGURED__");
|
||||
});
|
||||
|
||||
await expect(main(standardIo)).rejects.toThrow("__LOG_HANDLER_CONFIGURED__");
|
||||
return standardIo;
|
||||
}
|
||||
|
||||
describe("CLI log handler", () => {
|
||||
const originalArgv = process.argv.slice();
|
||||
let databasePath: string;
|
||||
@@ -43,6 +65,7 @@ describe("CLI log handler", () => {
|
||||
|
||||
afterEach(async () => {
|
||||
process.argv = originalArgv.slice();
|
||||
setGlobalLogFunction(() => {});
|
||||
await rm(databasePath, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
@@ -52,4 +75,43 @@ describe("CLI log handler", () => {
|
||||
await expect(main(createStandardIoMock())).rejects.toThrow("__LOG_HANDLER_CONFIGURED__");
|
||||
expect(mocks.setLogHandler).toHaveBeenCalledWith(expect.any(Function), true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["NOTICE", LOG_LEVEL_NOTICE],
|
||||
["URGENT", LOG_LEVEL_URGENT],
|
||||
["DEBUG", LOG_LEVEL_DEBUG],
|
||||
["INFO", LOG_LEVEL_INFO],
|
||||
["VERBOSE", LOG_LEVEL_VERBOSE],
|
||||
] as const)("keeps Commonlib %s diagnostics hidden by default", async (name, level) => {
|
||||
const message = `Commonlib ${name} diagnostic`;
|
||||
const standardIo = await invokeGlobalLogger(databasePath, message, level);
|
||||
|
||||
expect(standardIo.writeStderr).not.toHaveBeenCalledWith(expect.stringContaining(message));
|
||||
expect(standardIo.writeStdout).not.toHaveBeenCalledWith(expect.stringContaining(message));
|
||||
});
|
||||
|
||||
it("writes detailed Commonlib diagnostics to stderr in debug mode", async () => {
|
||||
const messages = [
|
||||
["NOTICE", LOG_LEVEL_NOTICE],
|
||||
["URGENT", LOG_LEVEL_URGENT],
|
||||
["DEBUG", LOG_LEVEL_DEBUG],
|
||||
["INFO", LOG_LEVEL_INFO],
|
||||
["VERBOSE", LOG_LEVEL_VERBOSE],
|
||||
] as const;
|
||||
process.argv = ["node", "livesync-cli", databasePath, "--debug", "remote-ls"];
|
||||
const standardIo = createStandardIoMock();
|
||||
mocks.setLogHandler.mockImplementation(() => {
|
||||
for (const [name, level] of messages) Logger(`Commonlib ${name} diagnostic`, level);
|
||||
throw new Error("__LOG_HANDLER_CONFIGURED__");
|
||||
});
|
||||
|
||||
await expect(main(standardIo)).rejects.toThrow("__LOG_HANDLER_CONFIGURED__");
|
||||
|
||||
for (const [name, level] of messages) {
|
||||
expect(standardIo.writeStderr).toHaveBeenCalledWith(
|
||||
expect.stringContaining(`[${level}] Commonlib ${name} diagnostic\n`)
|
||||
);
|
||||
}
|
||||
expect(standardIo.writeStdout).not.toHaveBeenCalledWith(expect.stringContaining("Commonlib"));
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
@@ -106,6 +108,8 @@ Arguments:
|
||||
Commands:
|
||||
daemon (default) Run mirror scan then continuously sync CouchDB <-> local filesystem
|
||||
sync Run one replication cycle and exit
|
||||
journal-skip <journal-key>
|
||||
Retry replication, skip only the matching stopped Journal, and resume
|
||||
p2p-peers <timeout> Show discovered peers as [peer]\t<peer-id>\t<peer-name>
|
||||
p2p-sync <peer> <timeout>
|
||||
Sync with the specified peer-id or peer-name
|
||||
@@ -601,6 +605,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 +822,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;
|
||||
|
||||
@@ -72,6 +72,7 @@ describe("CLI parseArgs", () => {
|
||||
expect(combined).toContain("Usage:");
|
||||
expect(combined).toContain("livesync-cli <database-path> [options] <command> [command-args]");
|
||||
expect(combined).toContain("--compat-remote-admin-exit-zero");
|
||||
expect(combined).toContain("journal-skip <journal-key>");
|
||||
});
|
||||
|
||||
it("parses p2p-peers command and timeout", () => {
|
||||
@@ -101,6 +102,15 @@ describe("CLI parseArgs", () => {
|
||||
expect(parsed.commandArgs).toEqual([]);
|
||||
});
|
||||
|
||||
it("parses journal-skip and its exact Journal key", () => {
|
||||
process.argv = ["node", "livesync-cli", "./databasePath", "journal-skip", "7-docs.jsonl.gz"];
|
||||
const parsed = parseArgs();
|
||||
|
||||
expect(parsed.databasePath).toBe("./databasePath");
|
||||
expect(parsed.command).toBe("journal-skip");
|
||||
expect(parsed.commandArgs).toEqual(["7-docs.jsonl.gz"]);
|
||||
});
|
||||
|
||||
it.each(["rebuild", "fetch"] as const)("parses %s and its force flag", (command) => {
|
||||
process.argv = ["node", "livesync-cli", "./databasePath", "--force", command];
|
||||
const parsed = parseArgs();
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"buildRun": "npm run build && npm run cli --",
|
||||
"build:docker": "docker build -f Dockerfile -t livesync-cli ../../..",
|
||||
"check": "tsc -p tsconfig.json",
|
||||
"test:unit": "cd ../../.. && npx vitest run --config vitest.config.unit.ts src/apps/cli/main.unit.spec.ts src/apps/cli/main.bootstrap.unit.spec.ts src/apps/cli/settingsPersistence.unit.spec.ts src/apps/cli/runtimeState.unit.spec.ts src/apps/cli/services/NodeKeyValueDBService.unit.spec.ts src/apps/cli/services/NodeLocalStorage.unit.spec.ts src/apps/cli/managers/CLIStorageEventManagerAdapter.unit.spec.ts src/apps/cli/commands/utils.unit.spec.ts src/apps/cli/commands/runCommand.unit.spec.ts src/apps/cli/commands/daemonCommand.unit.spec.ts src/apps/cli/commands/p2p.unit.spec.ts src/apps/cli/deploy/install.unit.spec.ts src/apps/cli/adapters/NodeFileSystemAdapter.unit.spec.ts",
|
||||
"test:unit": "cd ../../.. && npx vitest run --config vitest.config.unit.ts src/apps/cli/main.unit.spec.ts src/apps/cli/main.bootstrap.unit.spec.ts src/apps/cli/settingsPersistence.unit.spec.ts src/apps/cli/runtimeState.unit.spec.ts src/apps/cli/services/NodeKeyValueDBService.unit.spec.ts src/apps/cli/services/NodeLocalStorage.unit.spec.ts src/apps/cli/managers/CLIStorageEventManagerAdapter.unit.spec.ts src/apps/cli/commands/utils.unit.spec.ts src/apps/cli/commands/runCommand.unit.spec.ts src/apps/cli/commands/journalSkip.unit.spec.ts src/apps/cli/commands/daemonCommand.unit.spec.ts src/apps/cli/commands/p2p.unit.spec.ts src/apps/cli/deploy/install.unit.spec.ts src/apps/cli/adapters/NodeFileSystemAdapter.unit.spec.ts",
|
||||
"test:e2e:two-vaults": "bash test/test-e2e-two-vaults-with-docker-linux.sh",
|
||||
"test:e2e:two-vaults:common": "bash test/test-e2e-two-vaults-common.sh",
|
||||
"test:e2e:two-vaults:matrix": "bash test/test-e2e-two-vaults-matrix.sh",
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,21 @@ export const liveSyncProvisionalEnglishMessages = {
|
||||
"SyncGuard minimum files": "SyncGuard minimum files",
|
||||
"Minimum number of Vault files required for SyncGuard protection to apply.":
|
||||
"Minimum number of Vault files required for SyncGuard protection to apply.",
|
||||
"Skip stopped Journal and resume": "Skip stopped Journal and resume",
|
||||
"Object Storage only. Confirm to skip the currently stopped Journal and resume replication. The confirmation names the exact Journal key and failure stage. Skipping may leave content or history missing; the next Journal failure still stops replication.":
|
||||
"Object Storage only. Confirm to skip the currently stopped Journal and resume replication. The confirmation names the exact Journal key and failure stage. Skipping may leave content or history missing; the next Journal failure still stops replication.",
|
||||
"Skip and resume": "Skip and resume",
|
||||
"No stopped Journal is available. Retry replication to identify the stopped Journal.":
|
||||
"No stopped Journal is available. Retry replication to identify the stopped Journal.",
|
||||
"Skip stopped Journal '${KEY}' at failure stage '${STAGE}' and resume replication? This may leave content or history missing.":
|
||||
"Skip stopped Journal '${KEY}' at failure stage '${STAGE}' and resume replication? This may leave content or history missing.",
|
||||
"The active replicator changed. Retry replication to identify the stopped Journal.":
|
||||
"The active replicator changed. Retry replication to identify the stopped Journal.",
|
||||
"Journal receipt did not complete after this action. Check the current failure before retrying.":
|
||||
"Journal receipt did not complete after this action. Check the current failure before retrying.",
|
||||
"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…",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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({});
|
||||
});
|
||||
});
|
||||
@@ -82,6 +82,7 @@ export function getCouchDBReplicatorConfigurationIdentity(settings: RemoteDBSett
|
||||
authentication,
|
||||
projectHeaders(settings.couchDB_CustomHeaders),
|
||||
settings.useRequestAPI,
|
||||
settings.useCouchDBCacheControlHeadersOnly === true,
|
||||
settings.disableRequestURI,
|
||||
projectRemoteSecurity(settings),
|
||||
usesEncryptedInternalMetadata(settings),
|
||||
|
||||
@@ -7,6 +7,17 @@ import {
|
||||
} from "./replicatorConfigurationIdentity";
|
||||
|
||||
describe("active Replicator configuration identity", () => {
|
||||
it("treats an absent headers-only CouchDB cache switch as off and leaves Object Storage unaffected", () => {
|
||||
const absent = configuredSettings();
|
||||
Reflect.deleteProperty(absent, "useCouchDBCacheControlHeadersOnly");
|
||||
expect(getCouchDBReplicatorConfigurationIdentity({ ...absent, useCouchDBCacheControlHeadersOnly: false })).toBe(
|
||||
getCouchDBReplicatorConfigurationIdentity(absent)
|
||||
);
|
||||
expect(getObjectStorageReplicatorConfigurationIdentity({ ...absent, useCouchDBCacheControlHeadersOnly: true })).toBe(
|
||||
getObjectStorageReplicatorConfigurationIdentity(absent)
|
||||
);
|
||||
});
|
||||
|
||||
it("replaces a connection when the path identity mode changes", () => {
|
||||
const previous = configuredSettings({ pathNormalizationVersion: 0 });
|
||||
const next = { ...previous, pathNormalizationVersion: 1 as const };
|
||||
@@ -73,6 +84,7 @@ describe("active Replicator configuration identity", () => {
|
||||
["couchDB_PASSWORD", "secret-b"],
|
||||
["couchDB_CustomHeaders", "X-First: changed"],
|
||||
["useRequestAPI", true],
|
||||
["useCouchDBCacheControlHeadersOnly", true],
|
||||
["disableRequestURI", true],
|
||||
["encrypt", false],
|
||||
["passphrase", "encryption-b"],
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -81,6 +81,10 @@ function createBufferedLog(entries: ReactiveSource<LogEntry[]>) {
|
||||
};
|
||||
}
|
||||
|
||||
function logToConsole(message: unknown, level?: number, key?: string) {
|
||||
console.log({ message, level, key });
|
||||
}
|
||||
|
||||
// Legacy Logger calls use this shared buffer; service logs use their owner's buffer.
|
||||
const recentGlobalLogEntries = reactiveSource<LogEntry[]>([]);
|
||||
setGlobalLogFunction(createBufferedLog(recentGlobalLogEntries));
|
||||
@@ -126,7 +130,7 @@ function redactLog(log: string) {
|
||||
const showDebugLog = false;
|
||||
export const MARK_DONE = "\u{2009}\u{2009}";
|
||||
export class ModuleLog extends AbstractObsidianModule {
|
||||
/** The host releases this connection after the complete asynchronous unload. */
|
||||
/** The host releases log display subscriptions after the complete asynchronous unload. */
|
||||
logResource!: OwnedResource<LogFunction>;
|
||||
private readonly recentLogEntries = reactiveSource<LogEntry[]>([]);
|
||||
private readonly displayLogEntries = () => {
|
||||
@@ -615,8 +619,10 @@ ${stringifyYaml(info)}
|
||||
}
|
||||
}
|
||||
override onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
|
||||
const log = createBufferedLog(this.recentLogEntries);
|
||||
const unregisterLog = services.API.addLog.setHandler(log);
|
||||
const bufferedLog = createBufferedLog(this.recentLogEntries);
|
||||
let logTarget: LogFunction = bufferedLog;
|
||||
const log: LogFunction = (message, level, key) => logTarget(message, level, key);
|
||||
services.API.addLog.setHandler(log);
|
||||
const collectGlobalLogs = () => {
|
||||
const entries = recentGlobalLogEntries.value;
|
||||
if (entries.length === 0) return;
|
||||
@@ -624,7 +630,7 @@ ${stringifyYaml(info)}
|
||||
this.recentLogEntries.value = [...this.recentLogEntries.value, ...entries];
|
||||
};
|
||||
this.logResource = createOwnedResource(log, () => {
|
||||
unregisterLog();
|
||||
logTarget = logToConsole;
|
||||
recentGlobalLogEntries.offChanged(collectGlobalLogs);
|
||||
this.recentLogEntries.offChanged(this.displayLogEntries);
|
||||
this.recentLogEntries.value = [];
|
||||
|
||||
@@ -107,25 +107,92 @@ describe("ModuleLog service connection", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("detaches both service registration and legacy subscription on disposal", async () => {
|
||||
it("keeps service logging callable after disposal and sends late logs to the console", async () => {
|
||||
const { services, module, sink } = await createHarness();
|
||||
await services.appLifecycle.onSettingLoaded();
|
||||
sink.mockClear();
|
||||
const consoleLog = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
||||
|
||||
await module.logResource.dispose();
|
||||
try {
|
||||
expect(() => services.API.addLog("late-service", 32, "late-key")).not.toThrow();
|
||||
expect(consoleLog).toHaveBeenCalledExactlyOnceWith({
|
||||
message: "late-service",
|
||||
level: 32,
|
||||
key: "late-key",
|
||||
});
|
||||
const cause = new Error("root cause");
|
||||
const error = Object.assign(new Error("late failure"), { cause });
|
||||
services.API.addLog(error, 128, "late-error");
|
||||
const errorEntry = consoleLog.mock.calls[1][0] as {
|
||||
message: Error & { cause?: unknown };
|
||||
level?: number;
|
||||
key?: string;
|
||||
};
|
||||
expect(errorEntry.message).toBe(error);
|
||||
expect(errorEntry.message.cause).toBe(cause);
|
||||
expect(errorEntry.level).toBe(128);
|
||||
expect(errorEntry.key).toBe("late-error");
|
||||
expect(sink).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
consoleLog.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("sends late circular service messages directly to the console", async () => {
|
||||
const { services, module, sink } = await createHarness();
|
||||
await services.appLifecycle.onSettingLoaded();
|
||||
sink.mockClear();
|
||||
const consoleLog = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
||||
const circular: { self?: unknown } = {};
|
||||
circular.self = circular;
|
||||
|
||||
try {
|
||||
await module.logResource.dispose();
|
||||
expect(() => services.API.addLog(circular, 32, "circular")).not.toThrow();
|
||||
const consoleEntry = consoleLog.mock.calls[0][0] as {
|
||||
message: unknown;
|
||||
level?: number;
|
||||
key?: string;
|
||||
};
|
||||
expect(consoleEntry.message).toBe(circular);
|
||||
expect(consoleEntry.level).toBe(32);
|
||||
expect(consoleEntry.key).toBe("circular");
|
||||
expect(sink).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
consoleLog.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("releases legacy and UI subscriptions while retaining late service console logging", async () => {
|
||||
const first = await createHarness();
|
||||
const second = await createHarness();
|
||||
await first.services.appLifecycle.onSettingLoaded();
|
||||
await second.services.appLifecycle.onSettingLoaded();
|
||||
first.sink.mockClear();
|
||||
second.sink.mockClear();
|
||||
const consoleLog = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
||||
|
||||
await first.module.logResource.dispose();
|
||||
await first.module.logResource.dispose();
|
||||
expect(() => first.services.API.addLog("retired")).toThrow("not assigned");
|
||||
second.services.API.addLog("active", 32, "service");
|
||||
first.Logger("legacy-after-retirement", 32, "legacy");
|
||||
expect(first.sink).not.toHaveBeenCalled();
|
||||
expect(second.sink.mock.calls).toEqual([
|
||||
["active", 32, "service"],
|
||||
["legacy-after-retirement", 32, "legacy"],
|
||||
]);
|
||||
await second.module.logResource.dispose();
|
||||
try {
|
||||
await first.module.logResource.dispose();
|
||||
await first.module.logResource.dispose();
|
||||
expect(() => first.services.API.addLog("retired", 32, "retired-key")).not.toThrow();
|
||||
expect(consoleLog).toHaveBeenCalledWith({
|
||||
message: "retired",
|
||||
level: 32,
|
||||
key: "retired-key",
|
||||
});
|
||||
second.services.API.addLog("active", 32, "service");
|
||||
first.Logger("legacy-after-retirement", 32, "legacy");
|
||||
expect(first.sink).not.toHaveBeenCalled();
|
||||
expect(second.sink.mock.calls).toEqual([
|
||||
["active", 32, "service"],
|
||||
["legacy-after-retirement", 32, "legacy"],
|
||||
]);
|
||||
await second.module.logResource.dispose();
|
||||
} finally {
|
||||
consoleLog.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves a replacement service handler registered when the old owner is disposed", async () => {
|
||||
@@ -143,20 +210,31 @@ describe("ModuleLog service connection", () => {
|
||||
expect(sink).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("releases a connection which never reached the settings-loaded phase", async () => {
|
||||
it("retires a connection which never reached the settings-loaded phase", async () => {
|
||||
const { services, module, sink, Logger } = await createHarness();
|
||||
services.API.addLog("pending");
|
||||
await module.logResource.dispose();
|
||||
Logger("after-disposal");
|
||||
expect(sink).not.toHaveBeenCalled();
|
||||
expect(() => services.API.addLog("retired")).toThrow("not assigned");
|
||||
const consoleLog = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
||||
try {
|
||||
await module.logResource.dispose();
|
||||
Logger("after-disposal");
|
||||
expect(() => services.API.addLog("retired", 32, "retired-key")).not.toThrow();
|
||||
expect(consoleLog).toHaveBeenCalledWith({
|
||||
message: "retired",
|
||||
level: 32,
|
||||
key: "retired-key",
|
||||
});
|
||||
expect(sink).not.toHaveBeenCalled();
|
||||
|
||||
const replacement = await createHarness();
|
||||
await replacement.services.appLifecycle.onSettingLoaded();
|
||||
const messages = replacement.sink.mock.calls.map(([message]) => message);
|
||||
expect(messages).toContain("after-disposal");
|
||||
expect(messages).not.toContain("pending");
|
||||
await replacement.module.logResource.dispose();
|
||||
const replacement = await createHarness();
|
||||
await replacement.services.appLifecycle.onSettingLoaded();
|
||||
const messages = replacement.sink.mock.calls.map(([message]) => message);
|
||||
expect(messages).toContain("after-disposal");
|
||||
expect(messages).not.toContain("pending");
|
||||
expect(messages).not.toContain("retired");
|
||||
await replacement.module.logResource.dispose();
|
||||
} finally {
|
||||
consoleLog.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["complete", "fail"] as const)(
|
||||
@@ -219,10 +297,20 @@ describe("ModuleLog service connection", () => {
|
||||
"moduleLiveSyncMain.logUnloadingPlugin"
|
||||
);
|
||||
}
|
||||
expect(() => services.API.addLog("retired")).toThrow("not assigned");
|
||||
sink.mockClear();
|
||||
Logger("after-unload");
|
||||
expect(sink).not.toHaveBeenCalled();
|
||||
const consoleLog = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
||||
try {
|
||||
expect(() => services.API.addLog("late-after-close", 32, "late-after-close")).not.toThrow();
|
||||
expect(consoleLog).toHaveBeenCalledWith({
|
||||
message: "late-after-close",
|
||||
level: 32,
|
||||
key: "late-after-close",
|
||||
});
|
||||
sink.mockClear();
|
||||
Logger("after-unload");
|
||||
expect(sink).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
consoleLog.mockRestore();
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -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"),
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
+15
-3
@@ -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(
|
||||
"then 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();
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,8 @@ import { LOG_LEVEL_NOTICE, Logger } from "@vrtmrz/livesync-commonlib/compat/comm
|
||||
import { FlagFilesHumanReadable, FlagFilesOriginal } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { fireAndForget } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import { LiveSyncCouchDBReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator";
|
||||
import { isLiveSyncJournalReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator";
|
||||
import type { StoppedJournal } from "@vrtmrz/livesync-commonlib/compat/replication/journal/JournalSyncTypes";
|
||||
import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts";
|
||||
import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab";
|
||||
import { visibleOnly, type PageFunctions } from "./SettingPane";
|
||||
@@ -151,6 +153,80 @@ export function paneMaintenance(
|
||||
)
|
||||
.addOnUpdate(this.onlyOnCouchDB);
|
||||
|
||||
// Offer manual recovery after Journal receipt stops on a failure. Normal retries
|
||||
// revisit the same Journal; this action confirms and skips only that Journal,
|
||||
// resumes receipt, and still stops at the next Journal failure.
|
||||
new Setting(paneEl)
|
||||
.setName($msg("Skip stopped Journal and resume"))
|
||||
.setDesc(
|
||||
$msg(
|
||||
"Object Storage only. Confirm to skip the currently stopped Journal and resume replication. The confirmation names the exact Journal key and failure stage. Skipping may leave content or history missing; the next Journal failure still stops replication."
|
||||
)
|
||||
)
|
||||
.addButton((button) =>
|
||||
setButtonDestructiveState(button)
|
||||
.setButtonText($msg("Skip and resume"))
|
||||
.setDisabled(false)
|
||||
.onClick(async () => {
|
||||
const replicator = this.core.replicator;
|
||||
if (!isLiveSyncJournalReplicator(replicator)) {
|
||||
Logger(
|
||||
$msg(
|
||||
"No stopped Journal is available. Retry replication to identify the stopped Journal."
|
||||
),
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
return;
|
||||
}
|
||||
const stoppedJournal: Readonly<StoppedJournal> | undefined = replicator.getStoppedJournal();
|
||||
if (!stoppedJournal) {
|
||||
Logger(
|
||||
$msg(
|
||||
"No stopped Journal is available. Retry replication to identify the stopped Journal."
|
||||
),
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
return;
|
||||
}
|
||||
const confirmed =
|
||||
(await this.core.confirm.askYesNoDialog(
|
||||
$msg(
|
||||
"Skip stopped Journal '${KEY}' at failure stage '${STAGE}' and resume replication? This may leave content or history missing.",
|
||||
{ KEY: stoppedJournal.key, STAGE: stoppedJournal.stage }
|
||||
),
|
||||
{
|
||||
title: $msg("Skip stopped Journal and resume"),
|
||||
defaultOption: "No",
|
||||
}
|
||||
)) === "yes";
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
if (this.core.replicator !== replicator) {
|
||||
Logger(
|
||||
$msg(
|
||||
"The active replicator changed. Retry replication to identify the stopped Journal."
|
||||
),
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
return;
|
||||
}
|
||||
const resumed = await replicator.skipStoppedJournalAndResume(
|
||||
this.core.settings,
|
||||
stoppedJournal
|
||||
);
|
||||
if (!resumed) {
|
||||
Logger(
|
||||
$msg(
|
||||
"Journal receipt did not complete after this action. Check the current failure before retrying."
|
||||
),
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
}
|
||||
})
|
||||
)
|
||||
.addOnUpdate(this.onlyOnMinIO);
|
||||
|
||||
new Setting(paneEl)
|
||||
.setName($msg("Reset journal received history"))
|
||||
.setDesc(
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const maintenanceHarness = vi.hoisted(() => ({
|
||||
createdSettings: [] as Array<{ name: string; click?: () => Promise<void> }>,
|
||||
createdSettings: [] as Array<{
|
||||
name: string;
|
||||
click?: () => Promise<void>;
|
||||
toggleKey?: string;
|
||||
onUpdate?: () => { visibility?: boolean };
|
||||
}>,
|
||||
logger: vi.fn(),
|
||||
notice: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/common/events.ts", () => ({
|
||||
@@ -10,7 +16,19 @@ vi.mock("@/common/events.ts", () => ({
|
||||
eventHub: { emitEvent: vi.fn() },
|
||||
}));
|
||||
vi.mock("@/common/translation", () => ({
|
||||
$msg: (message: string) => message,
|
||||
$msg: (message: string, replacements?: Record<string, unknown>) => {
|
||||
for (const [key, value] of Object.entries(replacements ?? {})) {
|
||||
message = message.split(String.fromCharCode(36) + "{" + key + "}").join(String(value));
|
||||
}
|
||||
return message;
|
||||
},
|
||||
}));
|
||||
vi.mock("@/deps.ts", () => ({
|
||||
Notice: class {
|
||||
constructor(message: string) {
|
||||
maintenanceHarness.notice(message);
|
||||
}
|
||||
},
|
||||
}));
|
||||
vi.mock("@/serviceFeatures/setupObsidian/settingsReset.ts", () => ({
|
||||
createCoreSettingsAfterFullReset: vi.fn(),
|
||||
@@ -33,10 +51,19 @@ vi.mock("@vrtmrz/livesync-commonlib/compat/common/utils", () => ({
|
||||
vi.mock("@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator", () => ({
|
||||
LiveSyncCouchDBReplicator: class {},
|
||||
}));
|
||||
vi.mock("@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator", () => {
|
||||
class LiveSyncJournalReplicator {}
|
||||
return {
|
||||
LiveSyncJournalReplicator,
|
||||
isLiveSyncJournalReplicator: (value: unknown) => value instanceof LiveSyncJournalReplicator,
|
||||
};
|
||||
});
|
||||
vi.mock("./LiveSyncSetting.ts", () => ({
|
||||
LiveSyncSetting: class {
|
||||
name = "";
|
||||
click?: () => Promise<void>;
|
||||
toggleKey?: string;
|
||||
onUpdate?: () => { visibility?: boolean };
|
||||
|
||||
constructor() {
|
||||
maintenanceHarness.createdSettings.push(this);
|
||||
@@ -51,6 +78,11 @@ vi.mock("./LiveSyncSetting.ts", () => ({
|
||||
return this;
|
||||
}
|
||||
|
||||
autoWireToggle(key: string) {
|
||||
this.toggleKey = key;
|
||||
return this;
|
||||
}
|
||||
|
||||
addButton(callback: (button: this) => void) {
|
||||
callback(this);
|
||||
return this;
|
||||
@@ -73,7 +105,8 @@ vi.mock("./LiveSyncSetting.ts", () => ({
|
||||
return this;
|
||||
}
|
||||
|
||||
addOnUpdate() {
|
||||
addOnUpdate(onUpdate: () => { visibility?: boolean }) {
|
||||
this.onUpdate = onUpdate;
|
||||
return this;
|
||||
}
|
||||
},
|
||||
@@ -85,14 +118,61 @@ vi.mock("./settingComponentStyles.ts", () => ({
|
||||
setButtonDestructiveState: <T>(button: T) => button,
|
||||
}));
|
||||
|
||||
import { LiveSyncJournalReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator";
|
||||
import { paneMaintenance } from "./PaneMaintenance.ts";
|
||||
|
||||
afterEach(() => {
|
||||
maintenanceHarness.createdSettings.length = 0;
|
||||
maintenanceHarness.logger.mockClear();
|
||||
maintenanceHarness.notice.mockClear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
type TestStoppedJournal = Readonly<{ key: string; stage: string }>;
|
||||
type ConfirmationOptions = { title: string; defaultOption: "No" };
|
||||
type AskYesNoDialog = (message: string, options: ConfirmationOptions) => Promise<"yes" | "no">;
|
||||
|
||||
function createJournalReplicator(stoppedJournal: TestStoppedJournal | undefined, result = true) {
|
||||
const getStoppedJournal = vi.fn(() => stoppedJournal);
|
||||
const skipStoppedJournalAndResume = vi.fn(async (_settings: unknown, _snapshot: unknown) => result);
|
||||
const replicator = Object.assign(new LiveSyncJournalReplicator({} as never), {
|
||||
getStoppedJournal,
|
||||
skipStoppedJournalAndResume,
|
||||
});
|
||||
return { replicator, getStoppedJournal, skipStoppedJournalAndResume };
|
||||
}
|
||||
|
||||
function createSyncingPane(replicator: unknown, askYesNoDialog: AskYesNoDialog = async () => "no") {
|
||||
const settings = { databaseName: "test-database" };
|
||||
const onlyOnMinIO = vi.fn(() => ({ visibility: true }));
|
||||
const confirmation = vi.fn(askYesNoDialog);
|
||||
const host = {
|
||||
core: { replicator, settings, confirm: { askYesNoDialog: confirmation } },
|
||||
createEl: vi.fn(),
|
||||
getMinioJournalSyncClient: vi.fn(),
|
||||
onlyOnCouchDB: vi.fn(),
|
||||
onlyOnCouchDBOrMinIO: vi.fn(),
|
||||
onlyOnMinIO,
|
||||
services: { replication: { markLocked: vi.fn(), markUnlocked: vi.fn() } },
|
||||
};
|
||||
const addPanel = vi.fn((_parent: HTMLElement, heading: string) => ({
|
||||
then(callback: (paneEl: HTMLElement) => void) {
|
||||
if (heading === "Syncing") {
|
||||
callback({} as HTMLElement);
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
}));
|
||||
|
||||
paneMaintenance.call(host as never, {} as HTMLElement, { addPanel } as never);
|
||||
|
||||
const setting = maintenanceHarness.createdSettings.find(({ name }) => name === "Skip stopped Journal and resume");
|
||||
if (!setting?.click) {
|
||||
throw new Error("Skip stopped Journal action was not registered");
|
||||
}
|
||||
return { setting, click: setting.click, host, settings, askYesNoDialog: confirmation, onlyOnMinIO };
|
||||
}
|
||||
|
||||
describe("paneMaintenance", () => {
|
||||
it("does not announce success when the remote wipe reports failure", async () => {
|
||||
const updateCheckPointInfo = vi.fn(async () => undefined);
|
||||
@@ -180,9 +260,145 @@ describe("paneMaintenance", () => {
|
||||
|
||||
expect(resetDatabase).toHaveBeenCalledOnce();
|
||||
expect(initialiseDatabase).toHaveBeenCalledOnce();
|
||||
expect(maintenanceHarness.logger).toHaveBeenCalledWith("Ui.Common.LocalDatabaseInitialisationFailed", "notice");
|
||||
});
|
||||
|
||||
it("removes the broken journal toggle and preserves received history reset semantics", async () => {
|
||||
const checkpoint = {
|
||||
receivedFiles: new Set(["received-file"]),
|
||||
knownIDs: new Set(["known-id"]),
|
||||
sentIDs: new Set(["sent-id"]),
|
||||
sentFiles: new Set(["sent-file"]),
|
||||
lastLocalSeq: 23,
|
||||
};
|
||||
let updatedCheckpoint: typeof checkpoint | undefined;
|
||||
const updateCheckPointInfo = vi.fn(async (update: (info: typeof checkpoint) => typeof checkpoint) => {
|
||||
updatedCheckpoint = update(checkpoint);
|
||||
});
|
||||
const onlyOnMinIO = vi.fn(() => ({ visibility: true }));
|
||||
const addPanel = vi.fn((_parent: HTMLElement, heading: string) => ({
|
||||
then(callback: (paneEl: HTMLElement) => void) {
|
||||
if (heading === "Syncing") {
|
||||
callback({} as HTMLElement);
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
}));
|
||||
const host = {
|
||||
core: { replicator: {}, storageAccess: {} },
|
||||
createEl: vi.fn(),
|
||||
getMinioJournalSyncClient: vi.fn(() => ({ updateCheckPointInfo })),
|
||||
onlyOnCouchDB: vi.fn(),
|
||||
onlyOnCouchDBOrMinIO: vi.fn(),
|
||||
onlyOnMinIO,
|
||||
services: { replication: { markLocked: vi.fn(), markUnlocked: vi.fn() } },
|
||||
};
|
||||
|
||||
paneMaintenance.call(host as never, {} as HTMLElement, { addPanel } as never);
|
||||
|
||||
expect(maintenanceHarness.createdSettings.some(({ toggleKey }) => toggleKey === "ignoreBrokenJournals")).toBe(
|
||||
false
|
||||
);
|
||||
const resetReceivedHistory = maintenanceHarness.createdSettings.find(
|
||||
({ name }) => name === "Reset journal received history"
|
||||
);
|
||||
if (!resetReceivedHistory?.click) {
|
||||
throw new Error("Reset journal received history action was not registered");
|
||||
}
|
||||
await resetReceivedHistory.click();
|
||||
|
||||
expect(updateCheckPointInfo).toHaveBeenCalledOnce();
|
||||
expect(updatedCheckpoint).toEqual({
|
||||
...checkpoint,
|
||||
receivedFiles: new Set(),
|
||||
knownIDs: new Set(),
|
||||
});
|
||||
expect(maintenanceHarness.logger).toHaveBeenCalledWith("Journal received history has been cleared.", "notice");
|
||||
});
|
||||
|
||||
it("asks the user to retry when there is no stopped Journal", async () => {
|
||||
const { replicator, getStoppedJournal, skipStoppedJournalAndResume } = createJournalReplicator(undefined);
|
||||
const rendered = createSyncingPane(replicator);
|
||||
|
||||
expect(rendered.setting.onUpdate).toBe(rendered.onlyOnMinIO);
|
||||
await rendered.click();
|
||||
|
||||
expect(getStoppedJournal).toHaveBeenCalledOnce();
|
||||
expect(rendered.askYesNoDialog).not.toHaveBeenCalled();
|
||||
expect(skipStoppedJournalAndResume).not.toHaveBeenCalled();
|
||||
expect(maintenanceHarness.logger).toHaveBeenCalledWith(
|
||||
"Ui.Common.LocalDatabaseInitialisationFailed",
|
||||
"No stopped Journal is available. Retry replication to identify the stopped Journal.",
|
||||
"notice"
|
||||
);
|
||||
expect(maintenanceHarness.notice).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not skip the stopped Journal when confirmation is cancelled", async () => {
|
||||
const stoppedJournal = { key: "journal/one", stage: "save-records" } as const;
|
||||
const { replicator, skipStoppedJournalAndResume } = createJournalReplicator(stoppedJournal);
|
||||
const rendered = createSyncingPane(replicator);
|
||||
|
||||
await rendered.click();
|
||||
|
||||
expect(rendered.askYesNoDialog).toHaveBeenCalledOnce();
|
||||
const call = rendered.askYesNoDialog.mock.calls[0];
|
||||
if (!call) {
|
||||
throw new Error("Stopped Journal confirmation was not requested");
|
||||
}
|
||||
const [message, options] = call;
|
||||
expect(message).toContain(stoppedJournal.key);
|
||||
expect(message).toContain(stoppedJournal.stage);
|
||||
expect(options).toEqual({ title: "Skip stopped Journal and resume", defaultOption: "No" });
|
||||
expect(skipStoppedJournalAndResume).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips and resumes with the exact confirmed stopped Journal snapshot", async () => {
|
||||
const stoppedJournal = { key: "journal/one", stage: "save-records" } as const;
|
||||
const { replicator, skipStoppedJournalAndResume } = createJournalReplicator(stoppedJournal);
|
||||
const confirm = vi.fn(async () => "yes" as const);
|
||||
const rendered = createSyncingPane(replicator, confirm);
|
||||
|
||||
await rendered.click();
|
||||
|
||||
expect(skipStoppedJournalAndResume).toHaveBeenCalledOnce();
|
||||
expect(skipStoppedJournalAndResume.mock.calls[0]?.[0]).toBe(rendered.settings);
|
||||
expect(skipStoppedJournalAndResume.mock.calls[0]?.[1]).toBe(stoppedJournal);
|
||||
});
|
||||
|
||||
it("does not skip after the active replicator changes during confirmation", async () => {
|
||||
const stoppedJournal = { key: "journal/one", stage: "save-records" } as const;
|
||||
const active = createJournalReplicator(stoppedJournal);
|
||||
const replacement = createJournalReplicator(stoppedJournal);
|
||||
const confirm = vi.fn(async () => "yes" as const);
|
||||
const rendered = createSyncingPane(active.replicator, confirm);
|
||||
confirm.mockImplementation(async () => {
|
||||
rendered.host.core.replicator = replacement.replicator;
|
||||
return "yes";
|
||||
});
|
||||
|
||||
await rendered.click();
|
||||
|
||||
expect(active.skipStoppedJournalAndResume).not.toHaveBeenCalled();
|
||||
expect(replacement.skipStoppedJournalAndResume).not.toHaveBeenCalled();
|
||||
expect(maintenanceHarness.logger).toHaveBeenCalledWith(
|
||||
"The active replicator changed. Retry replication to identify the stopped Journal.",
|
||||
"notice"
|
||||
);
|
||||
expect(maintenanceHarness.notice).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports when receipt does not complete after skip and resume", async () => {
|
||||
const stoppedJournal = { key: "journal/one", stage: "save-records" } as const;
|
||||
const { replicator, skipStoppedJournalAndResume } = createJournalReplicator(stoppedJournal, false);
|
||||
const rendered = createSyncingPane(replicator, async () => "yes");
|
||||
|
||||
await rendered.click();
|
||||
|
||||
expect(skipStoppedJournalAndResume).toHaveBeenCalledOnce();
|
||||
expect(maintenanceHarness.logger).toHaveBeenCalledWith(
|
||||
"Journal receipt did not complete after this action. Check the current failure before retrying.",
|
||||
"notice"
|
||||
);
|
||||
expect(maintenanceHarness.notice).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,7 @@ export function panePowerUsers(
|
||||
onUpdate: this.onlyOnCouchDB,
|
||||
});
|
||||
new Setting(paneEl).autoWireToggle("useTimeouts", { onUpdate: this.onlyOnCouchDB });
|
||||
new Setting(paneEl).autoWireToggle("useCouchDBCacheControlHeadersOnly", { onUpdate: this.onlyOnCouchDB });
|
||||
});
|
||||
void addPanel(paneEl, $msg("Configuration Encryption")).then((paneEl) => {
|
||||
const passphrase_options: Record<ConfigPassphraseStore, string> = {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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®ion=auto",
|
||||
isEncrypted: false,
|
||||
},
|
||||
},
|
||||
archive: {
|
||||
id: "archive",
|
||||
name: "Archive bucket",
|
||||
uri: "sls+s3://key:secret@storage.example/?endpoint=https%3A%2F%2Fstorage.example&bucket=archive®ion=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();
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ObsidianConfirm } from "./ObsidianConfirm";
|
||||
import type { Confirm } from "@vrtmrz/livesync-commonlib/compat/interfaces/Confirm";
|
||||
import { requestUrl, type RequestUrlParam } from "@/deps";
|
||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
import { createNativeRequestHeaders } from "./nativeRequestHeaders";
|
||||
// All Services will be migrated to be based on Plain Services, not Injectable Services.
|
||||
// This is a migration step.
|
||||
|
||||
@@ -16,43 +17,6 @@ declare module "obsidian" {
|
||||
}
|
||||
}
|
||||
|
||||
const cacheReuseDirectives = new Set(["max-age", "max-stale", "min-fresh", "only-if-cached"]);
|
||||
|
||||
function splitHeaderDirectives(value: string): string[] {
|
||||
const directives: string[] = [];
|
||||
let start = 0;
|
||||
let inQuotes = false;
|
||||
let escaped = false;
|
||||
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
const character = value[index];
|
||||
if (inQuotes && escaped) {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (inQuotes && character === "\\") {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (character === '"') {
|
||||
inQuotes = !inQuotes;
|
||||
} else if (character === "," && !inQuotes) {
|
||||
const directive = value.slice(start, index).trim();
|
||||
if (directive) directives.push(directive);
|
||||
start = index + 1;
|
||||
}
|
||||
}
|
||||
|
||||
const lastDirective = value.slice(start).trim();
|
||||
if (lastDirective) directives.push(lastDirective);
|
||||
return directives;
|
||||
}
|
||||
|
||||
function getDirectiveName(directive: string): string {
|
||||
const equalsIndex = directive.indexOf("=");
|
||||
return (equalsIndex === -1 ? directive : directive.slice(0, equalsIndex)).trim().toLowerCase();
|
||||
}
|
||||
|
||||
export class ObsidianAPIService extends InjectableAPIService<ObsidianServiceContext> {
|
||||
_customHandler: ObsHttpHandler | undefined;
|
||||
_confirmInstance: Confirm;
|
||||
@@ -212,37 +176,8 @@ export class ObsidianAPIService extends InjectableAPIService<ObsidianServiceCont
|
||||
}
|
||||
}
|
||||
}
|
||||
const transformedHeaders = { ...optHeaders };
|
||||
// Delete headers that should not be sent by native fetch,
|
||||
// they are controlled by the browser and may cause CORS preflight failure if sent manually.
|
||||
delete transformedHeaders["host"];
|
||||
delete transformedHeaders["Host"];
|
||||
delete transformedHeaders["content-length"];
|
||||
delete transformedHeaders["Content-Length"];
|
||||
const cacheMode = opts?.cache ?? (req instanceof Request ? req.cache : undefined);
|
||||
if (cacheMode === "no-store" && ["GET", "HEAD"].includes(method.toUpperCase())) {
|
||||
// requestUrl has no cache option; no-store alone can reuse an existing cached response.
|
||||
const cacheControlKeys = Object.keys(transformedHeaders).filter(
|
||||
(key) => key.toLowerCase() === "cache-control"
|
||||
);
|
||||
const cacheControlDirectives = cacheControlKeys
|
||||
.flatMap((key) => splitHeaderDirectives(transformedHeaders[key]))
|
||||
.filter((directive) => {
|
||||
const name = getDirectiveName(directive);
|
||||
return !cacheReuseDirectives.has(name) && name !== "no-cache" && name !== "no-store";
|
||||
});
|
||||
const cacheControlKey = cacheControlKeys[0] ?? "cache-control";
|
||||
for (const key of cacheControlKeys) delete transformedHeaders[key];
|
||||
transformedHeaders[cacheControlKey] = [...cacheControlDirectives, "no-cache", "no-store"].join(", ");
|
||||
|
||||
const pragmaKeys = Object.keys(transformedHeaders).filter((key) => key.toLowerCase() === "pragma");
|
||||
const pragmaDirectives = pragmaKeys
|
||||
.flatMap((key) => splitHeaderDirectives(transformedHeaders[key]))
|
||||
.filter((directive) => getDirectiveName(directive) !== "no-cache");
|
||||
const pragmaKey = pragmaKeys[0] ?? "pragma";
|
||||
for (const key of pragmaKeys) delete transformedHeaders[key];
|
||||
transformedHeaders[pragmaKey] = [...pragmaDirectives, "no-cache"].join(", ");
|
||||
}
|
||||
const transformedHeaders = createNativeRequestHeaders(optHeaders, method, cacheMode);
|
||||
const contentType =
|
||||
transformedHeaders["content-type"] ?? transformedHeaders["Content-Type"] ?? "application/json";
|
||||
const requestParam: RequestUrlParam = {
|
||||
|
||||
@@ -101,6 +101,35 @@ describe("ObsidianAPIService.nativeFetch cache control", () => {
|
||||
expect(mocks.requestUrl.mock.calls[0][0]).toMatchObject({ url, method });
|
||||
});
|
||||
|
||||
it.each(["GET", "HEAD", "get", "head"])("revalidates an explicit no-cache %s request", async (method) => {
|
||||
const headers = {
|
||||
"Cache-Control": 'public, max-age=3600, no-transform, custom="a,b"',
|
||||
"cAcHe-CoNtRoL": 'max-stale=30, min-fresh=5, only-if-cached, custom-two="max-age=1, only-if-cached"',
|
||||
Pragma: "cache",
|
||||
};
|
||||
const originalHeaders = { ...headers };
|
||||
|
||||
await createService({}).nativeFetch(url, { method, cache: "no-cache", headers });
|
||||
|
||||
expect(sentHeaders().get("cache-control")).toBe(
|
||||
'public, no-transform, custom="a,b", custom-two="max-age=1, only-if-cached", no-cache'
|
||||
);
|
||||
expect(sentHeaders().get("pragma")).toBe("cache, no-cache");
|
||||
expect(headers).toEqual(originalHeaders);
|
||||
expect(mocks.requestUrl.mock.calls[0][0]).toMatchObject({ url, method });
|
||||
});
|
||||
|
||||
it("preserves an explicit no-store directive when enforcing no-cache", async () => {
|
||||
const headers = { "Cache-Control": "no-store, max-age=3600", Pragma: "legacy" };
|
||||
const originalHeaders = { ...headers };
|
||||
|
||||
await createService({}).nativeFetch(url, { cache: "no-cache", headers });
|
||||
|
||||
expect(sentHeaders().get("cache-control")).toBe("no-store, no-cache");
|
||||
expect(sentHeaders().get("pragma")).toBe("legacy, no-cache");
|
||||
expect(headers).toEqual(originalHeaders);
|
||||
});
|
||||
|
||||
it("preserves non-conflicting cache directives when enforcing no-store", async () => {
|
||||
const headers = {
|
||||
"Cache-Control": 'max-age=3600, no-transform, custom="a,b"',
|
||||
@@ -140,6 +169,30 @@ describe("ObsidianAPIService.nativeFetch cache control", () => {
|
||||
expect(request.headers.has("cache-control")).toBe(false);
|
||||
});
|
||||
|
||||
it("uses a Request no-cache mode when the options do not override it", async () => {
|
||||
const request = new Request(url, {
|
||||
method: "HEAD",
|
||||
cache: "no-cache",
|
||||
headers: { "Cache-Control": "max-age=60", "X-Fixture": "request" },
|
||||
});
|
||||
const headers = new Headers({ "X-Other": "options" });
|
||||
|
||||
await createService({}).nativeFetch(request, { headers });
|
||||
|
||||
expect(sentHeaders().get("cache-control")).toBe("no-cache");
|
||||
expect(sentHeaders().get("x-fixture")).toBe("request");
|
||||
expect(sentHeaders().get("x-other")).toBe("options");
|
||||
expect(request.headers.get("cache-control")).toBe("max-age=60");
|
||||
expect(headers.has("cache-control")).toBe(false);
|
||||
});
|
||||
|
||||
it("lets an explicit default cache mode override a no-cache Request", async () => {
|
||||
await createService({}).nativeFetch(new Request(url, { cache: "no-cache" }), { cache: "default" });
|
||||
|
||||
expect(sentHeaders().has("cache-control")).toBe(false);
|
||||
expect(sentHeaders().has("pragma")).toBe(false);
|
||||
});
|
||||
|
||||
it("lets the options request no-store for an otherwise cacheable Request", async () => {
|
||||
await createService({}).nativeFetch(new Request(url, { cache: "force-cache" }), { cache: "no-store" });
|
||||
|
||||
@@ -153,7 +206,7 @@ describe("ObsidianAPIService.nativeFetch cache control", () => {
|
||||
expect(sentHeaders().has("pragma")).toBe(false);
|
||||
});
|
||||
|
||||
it.each([undefined, "default", "no-cache", "reload", "force-cache"] as const)(
|
||||
it.each([undefined, "default", "reload", "force-cache"] as const)(
|
||||
"preserves caller headers for cache mode %s",
|
||||
async (cache) => {
|
||||
const headers = { "Cache-Control": "max-age=60", Pragma: "fixture" };
|
||||
@@ -171,6 +224,14 @@ describe("ObsidianAPIService.nativeFetch cache control", () => {
|
||||
expect(sentHeaders().has("pragma")).toBe(false);
|
||||
});
|
||||
|
||||
it.each(["POST", "PUT", "DELETE"])("preserves a no-cache %s request", async (method) => {
|
||||
const headers = { "Cache-Control": "max-age=60, no-transform", Pragma: "fixture" };
|
||||
|
||||
await createService({}).nativeFetch(url, { method, cache: "no-cache", headers });
|
||||
|
||||
expect(mocks.requestUrl.mock.calls[0][0]).toMatchObject({ url, method, headers });
|
||||
});
|
||||
|
||||
it.each(["POST", "PUT", "DELETE"])("preserves a no-store %s request", async (method) => {
|
||||
const headers = { "Cache-Control": "no-transform" };
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
const cacheReuseDirectives = new Set(["max-age", "max-stale", "min-fresh", "only-if-cached"]);
|
||||
|
||||
function splitHeaderDirectives(value: string): string[] {
|
||||
const directives: string[] = [];
|
||||
let start = 0;
|
||||
let inQuotes = false;
|
||||
let escaped = false;
|
||||
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
const character = value[index];
|
||||
if (inQuotes && escaped) {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (inQuotes && character === "\\") {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (character === '"') {
|
||||
inQuotes = !inQuotes;
|
||||
} else if (character === "," && !inQuotes) {
|
||||
const directive = value.slice(start, index).trim();
|
||||
if (directive) directives.push(directive);
|
||||
start = index + 1;
|
||||
}
|
||||
}
|
||||
|
||||
const lastDirective = value.slice(start).trim();
|
||||
if (lastDirective) directives.push(lastDirective);
|
||||
return directives;
|
||||
}
|
||||
|
||||
function getDirectiveName(directive: string): string {
|
||||
const equalsIndex = directive.indexOf("=");
|
||||
return (equalsIndex === -1 ? directive : directive.slice(0, equalsIndex)).trim().toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether native GET or HEAD requests must revalidate an explicitly bypassed cache.
|
||||
*
|
||||
* @param method - HTTP method, compared without regard to letter case.
|
||||
* @param cacheMode - Effective Request or options cache mode.
|
||||
* @returns True only for GET/HEAD with `no-cache` or `no-store`.
|
||||
*/
|
||||
export function shouldRevalidateNativeRequest(method: string, cacheMode: RequestCache | undefined): boolean {
|
||||
return (cacheMode === "no-cache" || cacheMode === "no-store") && ["GET", "HEAD"].includes(method.toUpperCase());
|
||||
}
|
||||
|
||||
function shouldRetainCacheControlDirective(directive: string, enforceNoStore: boolean): boolean {
|
||||
const name = getDirectiveName(directive);
|
||||
return !cacheReuseDirectives.has(name) && name !== "no-cache" && (!enforceNoStore || name !== "no-store");
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy request headers and apply the native fetch adapter's header/cache policy.
|
||||
*
|
||||
* @param headers - Merged input headers; this function does not mutate them.
|
||||
* @param method - HTTP method used to decide whether cache revalidation applies.
|
||||
* @param cacheMode - Effective Request or options cache mode.
|
||||
* @returns Fresh headers with the adapter's native-controlled headers removed.
|
||||
* Revalidated GET/HEAD requests retain non-conflicting directives and receive
|
||||
* `no-cache`; `no-store` also adds that directive. Existing header spelling is retained.
|
||||
*/
|
||||
export function createNativeRequestHeaders(
|
||||
headers: Readonly<Record<string, string>>,
|
||||
method: string,
|
||||
cacheMode: RequestCache | undefined
|
||||
): Record<string, string> {
|
||||
const transformedHeaders = { ...headers };
|
||||
// Delete headers that should not be sent by native fetch,
|
||||
// they are controlled by the browser and may cause CORS preflight failure if sent manually.
|
||||
delete transformedHeaders["host"];
|
||||
delete transformedHeaders["Host"];
|
||||
delete transformedHeaders["content-length"];
|
||||
delete transformedHeaders["Content-Length"];
|
||||
if (shouldRevalidateNativeRequest(method, cacheMode)) {
|
||||
// requestUrl has no cache option; no-store alone can reuse an existing cached response.
|
||||
const enforceNoStore = cacheMode === "no-store";
|
||||
const cacheControlKeys = Object.keys(transformedHeaders).filter((key) => key.toLowerCase() === "cache-control");
|
||||
const cacheControlDirectives = cacheControlKeys
|
||||
.flatMap((key) => splitHeaderDirectives(transformedHeaders[key]))
|
||||
.filter((directive) => shouldRetainCacheControlDirective(directive, enforceNoStore));
|
||||
const cacheControlKey = cacheControlKeys[0] ?? "cache-control";
|
||||
for (const key of cacheControlKeys) delete transformedHeaders[key];
|
||||
transformedHeaders[cacheControlKey] = [
|
||||
...cacheControlDirectives,
|
||||
"no-cache",
|
||||
...(enforceNoStore ? ["no-store"] : []),
|
||||
].join(", ");
|
||||
|
||||
const pragmaKeys = Object.keys(transformedHeaders).filter((key) => key.toLowerCase() === "pragma");
|
||||
const pragmaDirectives = pragmaKeys
|
||||
.flatMap((key) => splitHeaderDirectives(transformedHeaders[key]))
|
||||
.filter((directive) => getDirectiveName(directive) !== "no-cache");
|
||||
const pragmaKey = pragmaKeys[0] ?? "pragma";
|
||||
for (const key of pragmaKeys) delete transformedHeaders[key];
|
||||
transformedHeaders[pragmaKey] = [...pragmaDirectives, "no-cache"].join(", ");
|
||||
}
|
||||
return transformedHeaders;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createNativeRequestHeaders, shouldRevalidateNativeRequest } from "./nativeRequestHeaders";
|
||||
|
||||
describe("native cache revalidation decision", () => {
|
||||
it.each(["GET", "HEAD", "get", "head"])("revalidates explicit no-cache/no-store for %s", (method) => {
|
||||
expect(shouldRevalidateNativeRequest(method, "no-cache")).toBe(true);
|
||||
expect(shouldRevalidateNativeRequest(method, "no-store")).toBe(true);
|
||||
});
|
||||
|
||||
it.each([undefined, "default", "reload", "force-cache", "only-if-cached"] as const)(
|
||||
"does not strengthen cache mode %s",
|
||||
(mode) => {
|
||||
expect(shouldRevalidateNativeRequest("GET", mode)).toBe(false);
|
||||
expect(shouldRevalidateNativeRequest("HEAD", mode)).toBe(false);
|
||||
}
|
||||
);
|
||||
|
||||
it.each(["POST", "PUT", "PATCH", "DELETE", "OPTIONS"])("does not revalidate %s", (method) => {
|
||||
expect(shouldRevalidateNativeRequest(method, "no-cache")).toBe(false);
|
||||
expect(shouldRevalidateNativeRequest(method, "no-store")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("native request header creation", () => {
|
||||
it("copies input headers and removes the existing native-controlled header forms", () => {
|
||||
const input = Object.freeze({
|
||||
host: "first.test",
|
||||
Host: "second.test",
|
||||
"content-length": "1",
|
||||
"Content-Length": "2",
|
||||
Authorization: "Bearer fixture",
|
||||
"Cache-Control": "max-age=60",
|
||||
});
|
||||
const result = createNativeRequestHeaders(input, "GET", "default");
|
||||
expect(result).toEqual({ Authorization: "Bearer fixture", "Cache-Control": "max-age=60" });
|
||||
expect(result).not.toBe(input);
|
||||
expect(input.host).toBe("first.test");
|
||||
expect(input["Content-Length"]).toBe("2");
|
||||
});
|
||||
|
||||
it.each(["GET", "HEAD"])("creates no-store revalidation headers for %s", (method) => {
|
||||
expect(createNativeRequestHeaders({}, method, "no-store")).toEqual({
|
||||
"cache-control": "no-cache, no-store",
|
||||
pragma: "no-cache",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves an existing no-store directive when enforcing no-cache", () => {
|
||||
expect(
|
||||
createNativeRequestHeaders(
|
||||
{ "Cache-Control": "no-store, max-age=3600", Pragma: "legacy" },
|
||||
"GET",
|
||||
"no-cache"
|
||||
)
|
||||
).toEqual({ "Cache-Control": "no-store, no-cache", Pragma: "legacy, no-cache" });
|
||||
});
|
||||
|
||||
it("combines differently cased header fields and retains non-conflicting directives", () => {
|
||||
const input = Object.freeze({
|
||||
"Cache-Control": 'Max-Age=99, no-transform, custom="a,b", No-Cache',
|
||||
"cache-control": "max-stale=5, min-fresh=6, only-if-cached, NO-STORE, private",
|
||||
Pragma: 'legacy="a,b", No-Cache',
|
||||
pragma: "extension, no-cache",
|
||||
});
|
||||
expect(createNativeRequestHeaders(input, "get", "no-store")).toEqual({
|
||||
"Cache-Control": 'no-transform, custom="a,b", private, no-cache, no-store',
|
||||
Pragma: 'legacy="a,b", extension, no-cache',
|
||||
});
|
||||
expect(input["cache-control"]).toContain("max-stale=5");
|
||||
});
|
||||
|
||||
it("does not split directive commas inside escaped quoted strings", () => {
|
||||
const custom = String.raw`custom="a\"b,c"`;
|
||||
expect(createNativeRequestHeaders({ "cache-control": `${custom}, max-age=1` }, "HEAD", "no-cache")).toEqual({
|
||||
"cache-control": `${custom}, no-cache`,
|
||||
pragma: "no-cache",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["", "no-cache"],
|
||||
[", , ,", "no-cache"],
|
||||
[" , no-transform , , ", "no-transform, no-cache"],
|
||||
])("ignores empty header-list members in %j", (value, expected) => {
|
||||
const headers = Object.freeze({ "Cache-Control": value, Pragma: " , , " });
|
||||
|
||||
expect(createNativeRequestHeaders(headers, "GET", "no-cache")).toEqual({
|
||||
"Cache-Control": expected,
|
||||
Pragma: "no-cache",
|
||||
});
|
||||
expect(headers["Cache-Control"]).toBe(value);
|
||||
});
|
||||
|
||||
it("retains cache headers on a non-revalidated method", () => {
|
||||
const headers = { "cache-control": "max-age=99", Pragma: "legacy" };
|
||||
expect(createNativeRequestHeaders(headers, "POST", "no-store")).toEqual(headers);
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,7 @@ export interface ConflictCheckingDependencies {
|
||||
IConflictService,
|
||||
"getOptionalConflictCheckMethod" | "queueCheckFor" | "resolve" | "resolveByNewest"
|
||||
>;
|
||||
readonly signal: AbortSignal;
|
||||
readonly conflictProcessQueueCount: ReactiveSource<number>;
|
||||
readonly cancelActiveResolution: (filename: FilePathWithPrefix) => void;
|
||||
readonly currentSettings: () => ConflictCheckingSettings;
|
||||
@@ -27,6 +28,7 @@ export interface ConflictCheckingDependencies {
|
||||
export interface ConflictCheckingHandlers {
|
||||
readonly queueCheckForIfOpen: (file: FilePathWithPrefix) => Promise<void>;
|
||||
readonly queueCheckFor: (file: FilePathWithPrefix) => Promise<void>;
|
||||
/** Wait for active checks; false means the queue was cancelled during shutdown. */
|
||||
readonly ensureAllProcessed: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
@@ -45,11 +47,13 @@ export function createConflictCheckingHandlers(dependencies: ConflictCheckingDep
|
||||
// and automatic merges can use the remaining execution slots.
|
||||
concurrency: 10,
|
||||
onProgress(remaining) {
|
||||
// Accepted checks still update their accounting as they settle.
|
||||
dependencies.conflictProcessQueueCount.value = remaining;
|
||||
},
|
||||
});
|
||||
|
||||
const queueCheckForIfOpen = async (file: FilePathWithPrefix): Promise<void> => {
|
||||
if (dependencies.signal.aborted) return;
|
||||
const path = file;
|
||||
if (dependencies.currentSettings().checkConflictOnlyOnOpen) {
|
||||
const activeFile: FilePath | undefined = dependencies.vault.getActiveFilePath();
|
||||
@@ -62,7 +66,9 @@ export function createConflictCheckingHandlers(dependencies: ConflictCheckingDep
|
||||
};
|
||||
|
||||
const queueCheckFor = async (file: FilePathWithPrefix): Promise<void> => {
|
||||
if (dependencies.signal.aborted) return;
|
||||
const optionalConflictResult = await dependencies.conflict.getOptionalConflictCheckMethod(file);
|
||||
if (dependencies.signal.aborted) return;
|
||||
if (optionalConflictResult == true) {
|
||||
// The conflict has been resolved by another process.
|
||||
return;
|
||||
@@ -79,6 +85,16 @@ export function createConflictCheckingHandlers(dependencies: ConflictCheckingDep
|
||||
};
|
||||
|
||||
const ensureAllProcessed = (): Promise<boolean> => conflictQueue.waitForIdle();
|
||||
const abortQueue = (): void => {
|
||||
// Shutdown keeps its existing database-close order. Observe cleanup
|
||||
// without making the lifecycle wait for an active resolver.
|
||||
void conflictQueue.abort().catch((error) => dependencies.log(error, LOG_LEVEL_VERBOSE));
|
||||
};
|
||||
if (dependencies.signal.aborted) {
|
||||
abortQueue();
|
||||
} else {
|
||||
dependencies.signal.addEventListener("abort", abortQueue, { once: true });
|
||||
}
|
||||
|
||||
return {
|
||||
queueCheckForIfOpen,
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
type MetaEntry,
|
||||
type ObsidianLiveSyncSettings,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { EVENT_CONFLICT_CANCELLED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
|
||||
import { EVENT_CONFLICT_CANCELLED, EVENT_PLUGIN_UNLOADED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
|
||||
import { NO_INTERACTION } from "@vrtmrz/livesync-commonlib/replication";
|
||||
import type { IAPIService } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
|
||||
import {
|
||||
@@ -44,6 +44,7 @@ type HarnessOptions = {
|
||||
function createHarness(options: HarnessOptions = {}) {
|
||||
const context = createServiceContext();
|
||||
const conflict = new InjectableConflictService(context);
|
||||
const unloadHandlers: Array<() => Promise<boolean>> = [];
|
||||
const settings = { ...DEFAULT_SETTINGS, ...options.settings };
|
||||
const tryAutoMerge = vi.fn();
|
||||
const databaseFileAccess = {
|
||||
@@ -62,7 +63,14 @@ function createHarness(options: HarnessOptions = {}) {
|
||||
const activeFile = vi.fn(() => options.activeFile);
|
||||
const services = {
|
||||
API: { addLog },
|
||||
appLifecycle: { isSuspended: vi.fn(() => false) },
|
||||
appLifecycle: {
|
||||
isSuspended: vi.fn(() => false),
|
||||
onUnload: {
|
||||
addHandler: vi.fn((handler: () => Promise<boolean>) => {
|
||||
unloadHandlers.push(handler);
|
||||
}),
|
||||
},
|
||||
},
|
||||
conflict,
|
||||
context,
|
||||
database: { localDatabase: { tryAutoMerge } },
|
||||
@@ -88,9 +96,22 @@ function createHarness(options: HarnessOptions = {}) {
|
||||
serviceModules,
|
||||
storageAccess,
|
||||
tryAutoMerge,
|
||||
unload: async () => {
|
||||
for (const handler of unloadHandlers) await handler();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function leaf(rev: string, data: string, mtime: number): ConflictLeaf {
|
||||
return { rev, data, mtime, ctime: mtime, deleted: false };
|
||||
}
|
||||
@@ -919,6 +940,347 @@ describe("conflict resolution serviceFeature", () => {
|
||||
expect(harness.conflict.conflictProcessQueueCount.value).toBe(0);
|
||||
});
|
||||
|
||||
it("reports queue cancellation and logs a resolver rejection after unload", async () => {
|
||||
const path = "queued-rejection-after-unload.md" as FilePathWithPrefix;
|
||||
const harness = createHarness();
|
||||
const started = deferred<void>();
|
||||
const pendingResolve = deferred<void>();
|
||||
const resolve = vi.fn(async () => {
|
||||
started.resolve(undefined);
|
||||
await pendingResolve.promise;
|
||||
});
|
||||
harness.conflict.resolve.setHandler(resolve, true);
|
||||
|
||||
await harness.conflict.queueCheckFor(path);
|
||||
await started.promise;
|
||||
const completion = harness.conflict.ensureAllProcessed().then(
|
||||
(result) => ({ status: "resolved" as const, result }),
|
||||
(error: unknown) => ({ status: "rejected" as const, error })
|
||||
);
|
||||
|
||||
await harness.unload();
|
||||
await harness.unload();
|
||||
const failure = new Error("Resolver rejected after unload");
|
||||
pendingResolve.reject(failure);
|
||||
|
||||
await expect(completion).resolves.toEqual({ status: "resolved", result: false });
|
||||
expect(resolve).toHaveBeenCalledOnce();
|
||||
expect(harness.addLog).toHaveBeenCalledWith(failure, LOG_LEVEL_VERBOSE, "");
|
||||
});
|
||||
|
||||
it("allows an active inspection to complete normally after unload", async () => {
|
||||
const path = "not-conflicted-after-unload.md" as FilePathWithPrefix;
|
||||
const harness = createHarness();
|
||||
const queueCheckFor = vi.spyOn(harness.conflict, "queueCheckFor");
|
||||
const started = deferred<void>();
|
||||
const pendingMergeCheck = deferred<{ ok: typeof NOT_CONFLICTED }>();
|
||||
harness.tryAutoMerge.mockImplementation(async () => {
|
||||
started.resolve(undefined);
|
||||
return await pendingMergeCheck.promise;
|
||||
});
|
||||
const emitEvent = vi.spyOn(harness.context.events, "emitEvent");
|
||||
const operation = harness.conflict.resolve(path);
|
||||
const completion = operation.then(
|
||||
() => ({ status: "resolved" as const }),
|
||||
(error: unknown) => ({ status: "rejected" as const, error })
|
||||
);
|
||||
await started.promise;
|
||||
expect(harness.tryAutoMerge).toHaveBeenCalledWith(path, true);
|
||||
|
||||
harness.context.events.emitEvent(EVENT_PLUGIN_UNLOADED);
|
||||
pendingMergeCheck.resolve({ ok: NOT_CONFLICTED });
|
||||
|
||||
await expect(completion).resolves.toEqual({ status: "resolved" });
|
||||
expect(emitEvent).toHaveBeenCalledWith(EVENT_CONFLICT_CANCELLED, path);
|
||||
expect(queueCheckFor).not.toHaveBeenCalled();
|
||||
expect(harness.addLog).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("continues an accepted auto-merge when storage completes after unload", async () => {
|
||||
const path = "stored-merge-after-unload.md" as FilePathWithPrefix;
|
||||
const harness = createHarness({ settings: { syncAfterMerge: false } });
|
||||
const queueCheckFor = vi.spyOn(harness.conflict, "queueCheckFor");
|
||||
const started = deferred<void>();
|
||||
const pendingStore = deferred<boolean>();
|
||||
harness.tryAutoMerge.mockResolvedValue({ result: "Merged content\n", conflictedRev: "2-conflict" });
|
||||
harness.databaseFileAccess.storeContent.mockImplementation(async () => {
|
||||
started.resolve(undefined);
|
||||
return await pendingStore.promise;
|
||||
});
|
||||
const emitEvent = vi.spyOn(harness.context.events, "emitEvent");
|
||||
const operation = harness.conflict.resolve(path);
|
||||
const completion = operation.then(
|
||||
() => ({ status: "resolved" as const }),
|
||||
(error: unknown) => ({ status: "rejected" as const, error })
|
||||
);
|
||||
await started.promise;
|
||||
expect(harness.databaseFileAccess.storeContent).toHaveBeenCalledWith(path, "Merged content\n");
|
||||
|
||||
await harness.unload();
|
||||
pendingStore.resolve(true);
|
||||
|
||||
await expect(completion).resolves.toEqual({ status: "resolved" });
|
||||
expect(harness.fileHandler.deleteRevisionFromDB).toHaveBeenCalledWith(path, "2-conflict");
|
||||
expect(harness.fileHandler.dbToStorage).toHaveBeenCalled();
|
||||
expect(queueCheckFor).toHaveBeenCalledWith(path);
|
||||
expect(emitEvent).toHaveBeenCalledWith(EVENT_CONFLICT_CANCELLED, path);
|
||||
expect(harness.addLog).toHaveBeenCalled();
|
||||
await expect(harness.conflict.ensureAllProcessed()).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("reflects a revision deletion which succeeds after the unload event", async () => {
|
||||
const path = "deleted-after-unload.md" as FilePathWithPrefix;
|
||||
const harness = createHarness();
|
||||
const started = deferred<void>();
|
||||
const pendingDelete = deferred<boolean>();
|
||||
harness.fileHandler.deleteRevisionFromDB.mockImplementation(async () => {
|
||||
started.resolve(undefined);
|
||||
return await pendingDelete.promise;
|
||||
});
|
||||
const emitEvent = vi.spyOn(harness.context.events, "emitEvent");
|
||||
const operation = harness.conflict.resolveByDeletingRevision(path, "2-conflict", "UI Selected");
|
||||
const completion = operation.then(
|
||||
(result) => ({ status: "resolved" as const, result }),
|
||||
(error: unknown) => ({ status: "rejected" as const, error })
|
||||
);
|
||||
await started.promise;
|
||||
expect(harness.fileHandler.deleteRevisionFromDB).toHaveBeenCalledWith(path, "2-conflict");
|
||||
|
||||
harness.context.events.emitEvent(EVENT_PLUGIN_UNLOADED);
|
||||
pendingDelete.resolve(true);
|
||||
|
||||
await expect(completion).resolves.toEqual({ status: "resolved", result: AUTO_MERGED });
|
||||
expect(harness.databaseFileAccess.getConflictedRevs).toHaveBeenCalledWith(path);
|
||||
expect(harness.fileHandler.dbToStorage).toHaveBeenCalledWith(path, path, true);
|
||||
expect(emitEvent).toHaveBeenCalledWith(EVENT_CONFLICT_CANCELLED, path);
|
||||
expect(harness.addLog).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lets active replication finish while rejecting its next conflict check", async () => {
|
||||
const path = "replication-after-unload.md" as FilePathWithPrefix;
|
||||
const harness = createHarness({ settings: { syncAfterMerge: true } });
|
||||
const queueCheckFor = vi.spyOn(harness.conflict, "queueCheckFor");
|
||||
const started = deferred<void>();
|
||||
const pendingReplication = deferred<{ status: "completed" }>();
|
||||
harness.tryAutoMerge.mockResolvedValue({ ok: AUTO_MERGED });
|
||||
harness.services.replication.replicateUnattendedByEvent.mockImplementation(async () => {
|
||||
started.resolve(undefined);
|
||||
return await pendingReplication.promise;
|
||||
});
|
||||
const operation = harness.conflict.resolve(path);
|
||||
const completion = operation.then(
|
||||
() => ({ status: "resolved" as const }),
|
||||
(error: unknown) => ({ status: "rejected" as const, error })
|
||||
);
|
||||
await started.promise;
|
||||
expect(harness.services.replication.replicateUnattendedByEvent).toHaveBeenCalledWith({
|
||||
trigger: "merge",
|
||||
interaction: NO_INTERACTION,
|
||||
});
|
||||
|
||||
await harness.unload();
|
||||
pendingReplication.resolve({ status: "completed" });
|
||||
|
||||
await expect(completion).resolves.toEqual({ status: "resolved" });
|
||||
expect(queueCheckFor).toHaveBeenCalledWith(path);
|
||||
expect(harness.tryAutoMerge).toHaveBeenCalledOnce();
|
||||
await expect(harness.conflict.ensureAllProcessed()).resolves.toBe(false);
|
||||
expect(harness.addLog).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not start resolveByNewest when an optional conflict check returns newer after unload", async () => {
|
||||
const path = "optional-newer-after-unload.md" as FilePathWithPrefix;
|
||||
const harness = createHarness();
|
||||
const started = deferred<void>();
|
||||
const pendingOptionalCheck = deferred<"newer" | undefined>();
|
||||
const optionalCheck = vi.fn(async () => {
|
||||
started.resolve(undefined);
|
||||
return await pendingOptionalCheck.promise;
|
||||
});
|
||||
const resolveByNewest = vi.fn(async () => true);
|
||||
const unregisterOptionalCheck = harness.conflict.getOptionalConflictCheckMethod.addHandler(optionalCheck);
|
||||
harness.conflict.resolveByNewest.setHandler(resolveByNewest, true);
|
||||
const operation = harness.conflict.queueCheckFor(path);
|
||||
const completion = operation.then(
|
||||
() => ({ status: "resolved" as const }),
|
||||
(error: unknown) => ({ status: "rejected" as const, error })
|
||||
);
|
||||
try {
|
||||
await started.promise;
|
||||
expect(optionalCheck).toHaveBeenCalledWith(path);
|
||||
|
||||
await harness.unload();
|
||||
harness.addLog.mockImplementation(() => {
|
||||
throw new Error("Handler addLog not assigned");
|
||||
});
|
||||
pendingOptionalCheck.resolve("newer");
|
||||
|
||||
await expect(completion).resolves.toEqual({ status: "resolved" });
|
||||
expect(resolveByNewest).not.toHaveBeenCalled();
|
||||
expect(harness.addLog).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
unregisterOptionalCheck();
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["lifecycle", "event"] as const)(
|
||||
"stops admission and preserves ordinary database failures after %s disposal",
|
||||
async (trigger) => {
|
||||
const path = "operation-after-unload.md" as FilePathWithPrefix;
|
||||
const harness = createHarness();
|
||||
const optionalCheck = vi.fn(async () => "newer" as const);
|
||||
const unregisterOptionalCheck = harness.conflict.getOptionalConflictCheckMethod.addHandler(optionalCheck);
|
||||
const publicOperations = [
|
||||
vi.spyOn(harness.conflict, "queueCheckForIfOpen"),
|
||||
vi.spyOn(harness.conflict, "queueCheckFor"),
|
||||
vi.spyOn(harness.conflict, "resolve"),
|
||||
vi.spyOn(harness.conflict, "resolveByDeletingRevision"),
|
||||
vi.spyOn(harness.conflict, "resolveByNewest"),
|
||||
vi.spyOn(harness.conflict, "resolveAllConflictedFilesByNewerOnes"),
|
||||
];
|
||||
const emitEvent = vi.spyOn(harness.context.events, "emitEvent");
|
||||
|
||||
if (trigger === "lifecycle") {
|
||||
await harness.unload();
|
||||
} else {
|
||||
harness.context.events.emitEvent(EVENT_PLUGIN_UNLOADED);
|
||||
}
|
||||
const failure = new Error("database closed");
|
||||
harness.tryAutoMerge.mockRejectedValue(failure);
|
||||
harness.databaseFileAccess.fetchEntryMeta.mockResolvedValue(false);
|
||||
harness.fileHandler.deleteRevisionFromDB.mockResolvedValue(false);
|
||||
|
||||
try {
|
||||
const results = await Promise.allSettled([
|
||||
harness.conflict.queueCheckForIfOpen(path),
|
||||
harness.conflict.queueCheckFor(path),
|
||||
harness.conflict.resolve(path),
|
||||
harness.conflict.resolveByDeletingRevision(path, "2-conflict", "UI Selected"),
|
||||
harness.conflict.resolveByNewest(path),
|
||||
harness.conflict.resolveAllConflictedFilesByNewerOnes(),
|
||||
]);
|
||||
|
||||
expect(results).toEqual([
|
||||
{ status: "fulfilled", value: undefined },
|
||||
{ status: "fulfilled", value: undefined },
|
||||
{ status: "rejected", reason: failure },
|
||||
{ status: "fulfilled", value: MISSING_OR_ERROR },
|
||||
{ status: "fulfilled", value: false },
|
||||
{ status: "fulfilled", value: undefined },
|
||||
]);
|
||||
await expect(harness.conflict.ensureAllProcessed()).resolves.toBe(false);
|
||||
for (const operation of publicOperations) expect(operation).toHaveBeenCalledOnce();
|
||||
expect(optionalCheck).not.toHaveBeenCalled();
|
||||
expect(harness.tryAutoMerge).toHaveBeenCalledOnce();
|
||||
expect(harness.databaseFileAccess.fetchEntryMeta).toHaveBeenCalledOnce();
|
||||
expect(harness.databaseFileAccess.getConflictedRevs).not.toHaveBeenCalled();
|
||||
expect(harness.databaseFileAccess.storeContent).not.toHaveBeenCalled();
|
||||
expect(harness.fileHandler.deleteRevisionFromDB).toHaveBeenCalledOnce();
|
||||
expect(harness.fileHandler.dbToStorage).not.toHaveBeenCalled();
|
||||
expect(harness.storageAccess.getFileNames).toHaveBeenCalledOnce();
|
||||
expect(harness.services.replication.replicateUnattendedByEvent).not.toHaveBeenCalled();
|
||||
expect(emitEvent).not.toHaveBeenCalledWith(EVENT_CONFLICT_CANCELLED, path);
|
||||
expect(harness.addLog).toHaveBeenCalled();
|
||||
} finally {
|
||||
unregisterOptionalCheck();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it("cancels unstarted checks after active checks settle during unload", async () => {
|
||||
const paths = Array.from({ length: 11 }, (_, index) => `unload-concurrent-${index}.md` as FilePathWithPrefix);
|
||||
const harness = createHarness();
|
||||
const tenStarted = deferred<void>();
|
||||
const releaseResolvers = deferred<void>();
|
||||
const resolve = vi.fn(async (_path: FilePathWithPrefix) => {
|
||||
if (resolve.mock.calls.length === 10) tenStarted.resolve(undefined);
|
||||
await releaseResolvers.promise;
|
||||
});
|
||||
harness.conflict.resolve.setHandler(resolve, true);
|
||||
|
||||
await Promise.all(paths.map(async (path) => await harness.conflict.queueCheckFor(path)));
|
||||
await tenStarted.promise;
|
||||
expect(harness.conflict.conflictProcessQueueCount.value).toBe(11);
|
||||
|
||||
const completion = harness.conflict.ensureAllProcessed();
|
||||
harness.context.events.emitEvent(EVENT_PLUGIN_UNLOADED);
|
||||
releaseResolvers.resolve(undefined);
|
||||
|
||||
await expect(completion).resolves.toBe(false);
|
||||
expect(resolve).toHaveBeenCalledTimes(10);
|
||||
expect(harness.conflict.conflictProcessQueueCount.value).toBe(1);
|
||||
});
|
||||
|
||||
it("reports an unavailable database when an active bulk scan completes after unload", async () => {
|
||||
const path = "bulk-resolution-after-unload.md" as FilePathWithPrefix;
|
||||
const harness = createHarness();
|
||||
const namesStarted = deferred<void>();
|
||||
const pendingNames = deferred<FilePathWithPrefix[]>();
|
||||
harness.storageAccess.getFileNames.mockImplementation(async () => {
|
||||
namesStarted.resolve(undefined);
|
||||
return await pendingNames.promise;
|
||||
});
|
||||
|
||||
const operation = harness.conflict.resolveAllConflictedFilesByNewerOnes();
|
||||
await namesStarted.promise;
|
||||
expect(harness.addLog).toHaveBeenCalled();
|
||||
harness.addLog.mockClear();
|
||||
|
||||
await harness.unload();
|
||||
harness.databaseFileAccess.fetchEntryMeta.mockResolvedValue(false);
|
||||
pendingNames.resolve([path]);
|
||||
|
||||
await expect(operation).resolves.toBeUndefined();
|
||||
expect(harness.databaseFileAccess.fetchEntryMeta).toHaveBeenCalledWith(path, undefined, true);
|
||||
expect(harness.databaseFileAccess.getConflictedRevs).not.toHaveBeenCalled();
|
||||
expect(harness.fileHandler.deleteRevisionFromDB).not.toHaveBeenCalled();
|
||||
expect(harness.fileHandler.dbToStorage).not.toHaveBeenCalled();
|
||||
expect(
|
||||
harness.addLog.mock.calls.some(([message]) => String(message).includes("Could not get current revision"))
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps a replacement queue independent of repeated shutdown of the previous queue", async () => {
|
||||
const first = createHarness();
|
||||
const second = createHarness();
|
||||
first.tryAutoMerge.mockResolvedValue({ ok: NOT_CONFLICTED });
|
||||
second.tryAutoMerge.mockResolvedValue({ ok: NOT_CONFLICTED });
|
||||
|
||||
first.context.events.emitEvent(EVENT_PLUGIN_UNLOADED);
|
||||
await first.unload();
|
||||
await first.unload();
|
||||
await first.conflict.queueCheckFor("retired.md" as FilePathWithPrefix);
|
||||
await second.conflict.queueCheckFor("active.md" as FilePathWithPrefix);
|
||||
|
||||
await expect(first.conflict.ensureAllProcessed()).resolves.toBe(false);
|
||||
await expect(second.conflict.ensureAllProcessed()).resolves.toBe(true);
|
||||
expect(first.tryAutoMerge).not.toHaveBeenCalled();
|
||||
expect(second.tryAutoMerge).toHaveBeenCalledExactlyOnceWith("active.md", true);
|
||||
});
|
||||
|
||||
it("reports a storage failure after shutdown without treating the queue as completed", async () => {
|
||||
const harness = createHarness();
|
||||
const started = deferred<void>();
|
||||
const pendingStore = deferred<boolean>();
|
||||
const failure = new Error("database is closed");
|
||||
harness.tryAutoMerge.mockResolvedValue({ result: "Merged content", conflictedRev: "2-conflict" });
|
||||
harness.databaseFileAccess.storeContent.mockImplementation(() => {
|
||||
started.resolve(undefined);
|
||||
return pendingStore.promise;
|
||||
});
|
||||
|
||||
await harness.conflict.queueCheckFor("closed-database.md" as FilePathWithPrefix);
|
||||
await started.promise;
|
||||
const completion = harness.conflict.ensureAllProcessed();
|
||||
await harness.unload();
|
||||
pendingStore.reject(failure);
|
||||
|
||||
await expect(completion).resolves.toBe(false);
|
||||
expect(harness.fileHandler.deleteRevisionFromDB).not.toHaveBeenCalled();
|
||||
expect(harness.fileHandler.dbToStorage).not.toHaveBeenCalled();
|
||||
expect(harness.addLog).toHaveBeenCalledWith(failure, LOG_LEVEL_VERBOSE, "");
|
||||
});
|
||||
|
||||
it("drains requeued automatic checks when every resolver slot is occupied", async () => {
|
||||
const harness = createHarness({ settings: { syncAfterMerge: false } });
|
||||
const paths = Array.from({ length: 10 }, (_, index) => `requeue-${index}.md` as FilePathWithPrefix);
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import PouchDB from "pouchdb-core";
|
||||
import MemoryAdapter from "pouchdb-adapter-memory";
|
||||
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import { InjectableConflictService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableConflictService";
|
||||
import { LOG_LEVEL_VERBOSE, type FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { createConflictCheckingHandlers } from "./checker";
|
||||
|
||||
PouchDB.plugin(MemoryAdapter);
|
||||
|
||||
type Probe = { completed: number };
|
||||
|
||||
function deferred() {
|
||||
let resolve!: () => void;
|
||||
const promise = new Promise<void>((finish) => {
|
||||
resolve = finish;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
describe("conflict-check shutdown with a real PouchDB handle", () => {
|
||||
it.each([false, true])(
|
||||
"reports cancellation and preserves the database outcome when closed before completion: %s",
|
||||
async (closeDuringShutdown) => {
|
||||
const name = `conflict-shutdown-${crypto.randomUUID()}`;
|
||||
const db = new PouchDB<Probe>(name, { adapter: "memory" });
|
||||
const started = deferred();
|
||||
const release = deferred();
|
||||
const conflict = new InjectableConflictService(createServiceContext());
|
||||
const lifetime = new AbortController();
|
||||
const log = vi.fn();
|
||||
const resolver = vi.fn(async () => {
|
||||
started.resolve();
|
||||
await release.promise;
|
||||
const probe = await db.get("_local/shutdown-probe");
|
||||
await db.put({ ...probe, completed: probe.completed + 1 });
|
||||
});
|
||||
conflict.resolve.setHandler(resolver);
|
||||
const checking = createConflictCheckingHandlers({
|
||||
conflict,
|
||||
signal: lifetime.signal,
|
||||
conflictProcessQueueCount: conflict.conflictProcessQueueCount,
|
||||
cancelActiveResolution: vi.fn(),
|
||||
currentSettings: () => ({ checkConflictOnlyOnOpen: false }),
|
||||
vault: { getActiveFilePath: () => undefined },
|
||||
log,
|
||||
});
|
||||
conflict.queueCheckFor.setHandler(checking.queueCheckFor);
|
||||
let closed = false;
|
||||
let reopened: PouchDB.Database<Probe> | undefined;
|
||||
let completion: Promise<boolean> | undefined;
|
||||
try {
|
||||
await db.put({ _id: "_local/shutdown-probe", completed: 0 });
|
||||
await checking.queueCheckFor("active.md" as FilePathWithPrefix);
|
||||
await started.promise;
|
||||
completion = checking.ensureAllProcessed();
|
||||
lifetime.abort();
|
||||
lifetime.abort();
|
||||
await checking.queueCheckFor("late.md" as FilePathWithPrefix);
|
||||
if (closeDuringShutdown) {
|
||||
await db.close();
|
||||
closed = true;
|
||||
}
|
||||
release.resolve();
|
||||
|
||||
await expect(completion).resolves.toBe(false);
|
||||
expect(resolver).toHaveBeenCalledOnce();
|
||||
if (closeDuringShutdown) {
|
||||
expect(log).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: expect.stringMatching(/closed/i) }),
|
||||
LOG_LEVEL_VERBOSE
|
||||
);
|
||||
} else {
|
||||
expect(log).not.toHaveBeenCalled();
|
||||
await db.close();
|
||||
closed = true;
|
||||
}
|
||||
reopened = new PouchDB<Probe>(name, { adapter: "memory" });
|
||||
const persisted = await reopened.get("_local/shutdown-probe");
|
||||
expect(persisted.completed).toBe(closeDuringShutdown ? 0 : 1);
|
||||
} finally {
|
||||
release.resolve();
|
||||
await completion?.catch(() => undefined);
|
||||
if (!closed) await db.close();
|
||||
await (reopened ?? new PouchDB<Probe>(name, { adapter: "memory" })).destroy();
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import type { LogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib
|
||||
import type { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub";
|
||||
import { createConflictCheckingHandlers } from "./checker";
|
||||
import { createConflictResolutionOperations } from "./operations";
|
||||
import { EVENT_PLUGIN_UNLOADED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
|
||||
|
||||
export type ConflictResolutionHost = NecessaryServices<
|
||||
"appLifecycle" | "conflict" | "database" | "replication" | "setting" | "vault",
|
||||
@@ -18,6 +19,7 @@ export type ConflictResolutionHost = NecessaryServices<
|
||||
/** Compose the host-neutral conflict handlers with the host's borrowed logger. */
|
||||
export const useConflictResolutionFeature = createServiceFeature((host: ConflictResolutionHost): void => {
|
||||
const { services, serviceModules, log } = host;
|
||||
const lifetime = new AbortController();
|
||||
const operations = createConflictResolutionOperations({
|
||||
events: services.context.events,
|
||||
databaseFileAccess: serviceModules.databaseFileAccess,
|
||||
@@ -38,6 +40,7 @@ export const useConflictResolutionFeature = createServiceFeature((host: Conflict
|
||||
currentSettings: () => services.setting.currentSettings(),
|
||||
vault: services.vault,
|
||||
log,
|
||||
signal: lifetime.signal,
|
||||
});
|
||||
|
||||
services.conflict.queueCheckForIfOpen.setHandler(checking.queueCheckForIfOpen);
|
||||
@@ -47,6 +50,13 @@ export const useConflictResolutionFeature = createServiceFeature((host: Conflict
|
||||
services.conflict.resolve.setHandler(operations.resolve);
|
||||
services.conflict.resolveByNewest.setHandler(operations.resolveByNewest);
|
||||
services.conflict.resolveAllConflictedFilesByNewerOnes.setHandler(operations.resolveAllConflictedFilesByNewerOnes);
|
||||
|
||||
const stop = (): void => lifetime.abort();
|
||||
services.context.events.onceEvent(EVENT_PLUGIN_UNLOADED, stop, { signal: lifetime.signal });
|
||||
services.appLifecycle.onUnload.addHandler(() => {
|
||||
stop();
|
||||
return Promise.resolve(true);
|
||||
});
|
||||
});
|
||||
|
||||
export { createConflictCheckingHandlers } from "./checker";
|
||||
|
||||
@@ -27,9 +27,10 @@ import type { LiveSyncEventHub } from "@vrtmrz/livesync-commonlib/context";
|
||||
import type { StorageAccess } from "@vrtmrz/livesync-commonlib/compat/interfaces/StorageAccess";
|
||||
import { EVENT_CONFLICT_CANCELLED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
|
||||
import { NO_INTERACTION } from "@vrtmrz/livesync-commonlib/replication";
|
||||
import { parseDocumentPath, splitDocumentPath } from "@vrtmrz/livesync-commonlib/storage";
|
||||
import { isLockAcquired, withLock } from "octagonal-wheels/concurrency/lock";
|
||||
import diff_match_patch from "diff-match-patch";
|
||||
import { stripAllPrefixes, isPlainText } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
|
||||
import { isPlainText } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
|
||||
|
||||
export type ConflictResolutionSettings = Pick<
|
||||
ObsidianLiveSyncSettings,
|
||||
@@ -67,6 +68,7 @@ export interface ConflictResolutionOperations {
|
||||
readonly resolveAllConflictedFilesByNewerOnes: () => Promise<void>;
|
||||
}
|
||||
|
||||
/** Create conflict-resolution operations which retain their normal completion and failure paths. */
|
||||
export function createConflictResolutionOperations(
|
||||
dependencies: ConflictResolutionOperationsDependencies
|
||||
): ConflictResolutionOperations {
|
||||
@@ -108,7 +110,9 @@ export function createConflictResolutionOperations(
|
||||
return AUTO_MERGED;
|
||||
}
|
||||
// If no conflicts were found, write the resolved content to the storage.
|
||||
if (!(await dependencies.fileHandler.dbToStorage(path, stripAllPrefixes(path), true))) {
|
||||
const documentPath = parseDocumentPath(path);
|
||||
const { path: filePath } = splitDocumentPath(documentPath);
|
||||
if (!(await dependencies.fileHandler.dbToStorage(documentPath, filePath, true))) {
|
||||
dependencies.log(`Could not write the resolved content to the storage: ${path}`, LOG_LEVEL_NOTICE);
|
||||
return MISSING_OR_ERROR;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { FilePath, FilePathWithPrefix, UXFileInfo } from "@vrtmrz/livesync-
|
||||
import { readAsBlob } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import type { DatabaseFileAccess } from "@vrtmrz/livesync-commonlib/compat/interfaces/DatabaseFileAccess";
|
||||
import type { IFileHandler } from "@vrtmrz/livesync-commonlib/compat/interfaces/FileHandler";
|
||||
import { stripAllPrefixes } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
|
||||
import { parseDocumentPath, splitDocumentPath } from "@vrtmrz/livesync-commonlib/storage";
|
||||
|
||||
export type DocumentHistoryRestorationCore = {
|
||||
databaseFileAccess: Pick<
|
||||
@@ -54,8 +54,8 @@ export async function restoreDocumentHistoryRevision(
|
||||
}
|
||||
|
||||
const body = readAsBlob(source);
|
||||
const storagePath = stripAllPrefixes(current.path);
|
||||
if (storagePath !== current.path || !isPathValid(storagePath)) {
|
||||
const { prefix, path: storagePath } = splitDocumentPath(parseDocumentPath(current.path));
|
||||
if (prefix !== "" || !isPathValid(storagePath)) {
|
||||
return { status: "unsupported-path" };
|
||||
}
|
||||
const file: UXFileInfo = {
|
||||
|
||||
@@ -17,8 +17,10 @@ export function useInteractiveConflictResolutionFeature(
|
||||
createDialogue: ConflictResolveDialogueFactory
|
||||
): void {
|
||||
const services = host.services;
|
||||
const lifetime = new AbortController();
|
||||
const operations = createInteractiveConflictResolutionOperations({
|
||||
events: services.context.events,
|
||||
signal: lifetime.signal,
|
||||
databaseFileAccess: host.serviceModules.databaseFileAccess,
|
||||
localDatabase: () => services.database.localDatabase,
|
||||
confirm: services.UI.confirm,
|
||||
@@ -61,24 +63,18 @@ export function useInteractiveConflictResolutionFeature(
|
||||
});
|
||||
services.appLifecycle.getUnresolvedMessages.addHandler(operations.getActiveConflictMessages);
|
||||
services.conflict.resolveByUserInteraction.addHandler(operations.resolveByUserInteraction);
|
||||
const eventSubscriptions = new AbortController();
|
||||
const dispose = () => {
|
||||
// Stop the refresh listener before cancellation so that unloading does
|
||||
// not start a database read which can race with database disposal.
|
||||
eventSubscriptions.abort();
|
||||
operations.dispose();
|
||||
};
|
||||
services.context.events.onEvent(
|
||||
EVENT_CONFLICT_CANCELLED,
|
||||
(filename) => {
|
||||
operations.invalidateWaitingResolution(filename);
|
||||
fireAndForget(() => operations.refreshConflictState(filename));
|
||||
},
|
||||
{ signal: eventSubscriptions.signal }
|
||||
{ signal: lifetime.signal }
|
||||
);
|
||||
services.context.events.onceEvent(EVENT_PLUGIN_UNLOADED, dispose, { signal: eventSubscriptions.signal });
|
||||
const stop = (): void => lifetime.abort();
|
||||
services.context.events.onceEvent(EVENT_PLUGIN_UNLOADED, stop, { signal: lifetime.signal });
|
||||
services.appLifecycle.onUnload.addHandler(() => {
|
||||
dispose();
|
||||
stop();
|
||||
return Promise.resolve(true);
|
||||
});
|
||||
}
|
||||
|
||||
+485
-2
@@ -1,5 +1,7 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import { bindableFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/HandlerUtils";
|
||||
import { createInstanceLogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
|
||||
import {
|
||||
AUTO_MERGED,
|
||||
CANCELLED,
|
||||
@@ -30,8 +32,12 @@ async function* documents(items: unknown[]) {
|
||||
}
|
||||
}
|
||||
|
||||
function createOperations(conflictedRevisions: string[] = ["2-right"]) {
|
||||
function createOperations(
|
||||
conflictedRevisions: string[] = ["2-right"],
|
||||
overrides: Partial<InteractiveConflictResolutionOperationsDependencies> = {}
|
||||
) {
|
||||
const context = createServiceContext();
|
||||
const lifetime = new AbortController();
|
||||
let dialogueResult: unknown = POSTPONED;
|
||||
const constructed = { value: 0 };
|
||||
const getDBEntry = vi.fn(async (): Promise<false | { _rev: string; _conflicts?: string[] }> => false);
|
||||
@@ -50,6 +56,7 @@ function createOperations(conflictedRevisions: string[] = ["2-right"]) {
|
||||
const log = vi.fn();
|
||||
const operations = createInteractiveConflictResolutionOperations({
|
||||
events: context.events,
|
||||
signal: overrides.signal ?? lifetime.signal,
|
||||
databaseFileAccess: {
|
||||
getConflictedRevs,
|
||||
storeContent,
|
||||
@@ -75,8 +82,10 @@ function createOperations(conflictedRevisions: string[] = ["2-right"]) {
|
||||
};
|
||||
}),
|
||||
log,
|
||||
...overrides,
|
||||
} as unknown as InteractiveConflictResolutionOperationsDependencies);
|
||||
return {
|
||||
abort: () => lifetime.abort(),
|
||||
askSelectString,
|
||||
askInPopup,
|
||||
constructed,
|
||||
@@ -148,11 +157,13 @@ function createControlledDialogueFactory(
|
||||
|
||||
function createDialogueConcurrencyHarness() {
|
||||
const context = createServiceContext();
|
||||
const lifetime = new AbortController();
|
||||
const emitEvent = vi.spyOn(context.events, "emitEvent");
|
||||
const dialogues: ControlledDialogue[] = [];
|
||||
const createDialogue = createControlledDialogueFactory(context, dialogues);
|
||||
const operations = createInteractiveConflictResolutionOperations({
|
||||
events: context.events,
|
||||
signal: lifetime.signal,
|
||||
databaseFileAccess: {
|
||||
getConflictedRevs: vi.fn(async () => ["2-right"]),
|
||||
storeContent: vi.fn(async () => true),
|
||||
@@ -177,10 +188,447 @@ function createDialogueConcurrencyHarness() {
|
||||
createDialogue: createDialogue as ConflictResolveDialogueFactory,
|
||||
log: vi.fn(),
|
||||
} as unknown as InteractiveConflictResolutionOperationsDependencies);
|
||||
return { context, createDialogue, dialogues, emitEvent, operations };
|
||||
return { abort: () => lifetime.abort(), context, createDialogue, dialogues, emitEvent, operations };
|
||||
}
|
||||
|
||||
describe("interactive conflict resolution operations", () => {
|
||||
it("returns cancellation to the caller waiting for a requested conflict check", async () => {
|
||||
const harness = createOperations();
|
||||
harness.ensureAllProcessed.mockResolvedValue(false);
|
||||
|
||||
await expect(harness.operations.requestConflictResolution(path)).resolves.toBe(false);
|
||||
expect(harness.queueCheckFor).toHaveBeenCalledWith(path);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ label: "cancelled", result: CANCELLED },
|
||||
{ label: "postponed", result: POSTPONED },
|
||||
{ label: "selected", result: "2-right" },
|
||||
] as const)("ignores a $label dialogue result which settles after disposal", async ({ result }) => {
|
||||
let finish!: (result: MergeDialogResult) => void;
|
||||
const response = new Promise<MergeDialogResult>((resolve) => {
|
||||
finish = resolve;
|
||||
});
|
||||
const sink = vi.fn();
|
||||
const addLog = bindableFunction<(message: unknown) => void>("addLog");
|
||||
const unregisterLog = addLog.setHandler(sink);
|
||||
const createDialogue = vi.fn(() => ({ open: vi.fn(), waitForResult: () => response }));
|
||||
const fixture = createOperations(undefined, {
|
||||
createDialogue,
|
||||
log: createInstanceLogFunction("Conflict shutdown", { addLog } as never),
|
||||
});
|
||||
const active = fixture.operations.resolveByUserInteraction(path, conflict);
|
||||
await vi.waitFor(() => expect(createDialogue).toHaveBeenCalledOnce());
|
||||
const waiting = fixture.operations.resolveByUserInteraction("waiting.md" as FilePathWithPrefix, conflict);
|
||||
const settled = Promise.allSettled([active, waiting]);
|
||||
const logsBeforeDisposal = sink.mock.calls.length;
|
||||
|
||||
fixture.abort();
|
||||
unregisterLog();
|
||||
finish(result);
|
||||
|
||||
expect(await settled).toEqual([
|
||||
{ status: "fulfilled", value: false },
|
||||
{ status: "fulfilled", value: false },
|
||||
]);
|
||||
expect(createDialogue).toHaveBeenCalledOnce();
|
||||
expect(sink).toHaveBeenCalledTimes(logsBeforeDisposal);
|
||||
expect(fixture.getDBEntry).not.toHaveBeenCalled();
|
||||
expect(fixture.storeContent).not.toHaveBeenCalled();
|
||||
expect(fixture.resolveByDeletingRevision).not.toHaveBeenCalled();
|
||||
expect(fixture.replicateUnattendedByEvent).not.toHaveBeenCalled();
|
||||
expect(fixture.queueCheckFor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("logs a failed database read and returns normally after disposal", async () => {
|
||||
let finishRead!: (value: false | { _rev: string; _conflicts?: string[] }) => void;
|
||||
const pendingRead = new Promise<false | { _rev: string; _conflicts?: string[] }>((resolve) => {
|
||||
finishRead = resolve;
|
||||
});
|
||||
const sink = vi.fn();
|
||||
const addLog = bindableFunction<(message: unknown) => void>("addLog");
|
||||
addLog.setHandler(sink);
|
||||
const fixture = createOperations(undefined, {
|
||||
createDialogue: vi.fn(() => ({
|
||||
open: vi.fn(),
|
||||
waitForResult: vi.fn(async () => "2-right"),
|
||||
})),
|
||||
log: createInstanceLogFunction("Conflict shutdown", { addLog } as never),
|
||||
});
|
||||
fixture.getDBEntry.mockReturnValue(pendingRead);
|
||||
|
||||
const resolving = fixture.operations.resolveByUserInteraction(path, conflict);
|
||||
await vi.waitFor(() => expect(fixture.getDBEntry).toHaveBeenCalledOnce());
|
||||
const logsBeforeDisposal = sink.mock.calls.length;
|
||||
|
||||
fixture.abort();
|
||||
finishRead(false);
|
||||
|
||||
await expect(resolving).resolves.toBe(false);
|
||||
expect(sink).toHaveBeenCalledTimes(logsBeforeDisposal + 1);
|
||||
expect(sink).toHaveBeenCalledWith(
|
||||
expect.stringContaining(`Merge: Could not read ${path} from the local database`),
|
||||
expect.anything(),
|
||||
expect.anything()
|
||||
);
|
||||
expect(fixture.storeContent).not.toHaveBeenCalled();
|
||||
expect(fixture.resolveByDeletingRevision).not.toHaveBeenCalled();
|
||||
expect(fixture.replicateUnattendedByEvent).not.toHaveBeenCalled();
|
||||
expect(fixture.queueCheckFor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("continues a selected conflict after the database read succeeds following disposal", async () => {
|
||||
let finishRead!: (value: false | { _rev: string; _conflicts?: string[] }) => void;
|
||||
const pendingRead = new Promise<false | { _rev: string; _conflicts?: string[] }>((resolve) => {
|
||||
finishRead = resolve;
|
||||
});
|
||||
const fixture = createOperations(undefined, {
|
||||
createDialogue: vi.fn(() => ({
|
||||
open: vi.fn(),
|
||||
waitForResult: vi.fn(async () => "2-right"),
|
||||
})),
|
||||
});
|
||||
fixture.getDBEntry.mockReturnValue(pendingRead);
|
||||
|
||||
const resolving = fixture.operations.resolveByUserInteraction(path, conflict);
|
||||
await vi.waitFor(() => expect(fixture.getDBEntry).toHaveBeenCalledOnce());
|
||||
|
||||
fixture.abort();
|
||||
finishRead({ _rev: "2-left", _conflicts: ["2-right"] });
|
||||
|
||||
await expect(resolving).resolves.toBe(false);
|
||||
expect(fixture.resolveByDeletingRevision).toHaveBeenCalledWith(path, "2-right", "UI Selected");
|
||||
expect(fixture.replicateUnattendedByEvent).not.toHaveBeenCalled();
|
||||
expect(fixture.queueCheckFor).toHaveBeenCalledWith(path);
|
||||
});
|
||||
|
||||
it("logs a failed content store after disposal", async () => {
|
||||
let finishStore!: (stored: boolean) => void;
|
||||
const pendingStore = new Promise<boolean>((resolve) => {
|
||||
finishStore = resolve;
|
||||
});
|
||||
const sink = vi.fn();
|
||||
const addLog = bindableFunction<(message: unknown) => void>("addLog");
|
||||
addLog.setHandler(sink);
|
||||
const fixture = createOperations(undefined, {
|
||||
log: createInstanceLogFunction("Conflict shutdown", { addLog } as never),
|
||||
});
|
||||
fixture.dialogueResult.value = LEAVE_TO_SUBSEQUENT;
|
||||
fixture.getDBEntry.mockResolvedValue({ _rev: "2-left", _conflicts: ["2-right"] });
|
||||
fixture.storeContent.mockReturnValue(pendingStore);
|
||||
|
||||
const resolving = fixture.operations.resolveByUserInteraction(path, conflict);
|
||||
await vi.waitFor(() => expect(fixture.storeContent).toHaveBeenCalledOnce());
|
||||
const logsBeforeDisposal = sink.mock.calls.length;
|
||||
|
||||
fixture.abort();
|
||||
finishStore(false);
|
||||
|
||||
await expect(resolving).resolves.toBe(false);
|
||||
expect(sink).toHaveBeenCalledTimes(logsBeforeDisposal + 1);
|
||||
expect(sink).toHaveBeenCalledWith(
|
||||
`Concatenated content cannot be stored:${path}`,
|
||||
expect.anything(),
|
||||
expect.anything()
|
||||
);
|
||||
expect(fixture.resolveByDeletingRevision).not.toHaveBeenCalled();
|
||||
expect(fixture.replicateUnattendedByEvent).not.toHaveBeenCalled();
|
||||
expect(fixture.queueCheckFor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("continues a selected conflict after content storage succeeds following disposal", async () => {
|
||||
let finishStore!: (stored: boolean) => void;
|
||||
const pendingStore = new Promise<boolean>((resolve) => {
|
||||
finishStore = resolve;
|
||||
});
|
||||
const sink = vi.fn();
|
||||
const addLog = bindableFunction<(message: unknown) => void>("addLog");
|
||||
addLog.setHandler(sink);
|
||||
const fixture = createOperations(undefined, {
|
||||
log: createInstanceLogFunction("Conflict shutdown", { addLog } as never),
|
||||
});
|
||||
fixture.dialogueResult.value = LEAVE_TO_SUBSEQUENT;
|
||||
fixture.getDBEntry.mockResolvedValue({ _rev: "2-left", _conflicts: ["2-right"] });
|
||||
fixture.storeContent.mockReturnValue(pendingStore);
|
||||
fixture.currentSettings.mockReturnValue({ syncAfterMerge: true });
|
||||
|
||||
const resolving = fixture.operations.resolveByUserInteraction(path, conflict);
|
||||
await vi.waitFor(() => expect(fixture.storeContent).toHaveBeenCalledOnce());
|
||||
const logsBeforeDisposal = sink.mock.calls.length;
|
||||
|
||||
fixture.abort();
|
||||
finishStore(true);
|
||||
|
||||
await expect(resolving).resolves.toBe(false);
|
||||
expect(sink).toHaveBeenCalledTimes(logsBeforeDisposal);
|
||||
expect(fixture.resolveByDeletingRevision).toHaveBeenCalledWith(path, "2-right", "UI Concatenated");
|
||||
expect(fixture.replicateUnattendedByEvent).toHaveBeenCalledWith({
|
||||
trigger: "merge",
|
||||
interaction: expect.anything(),
|
||||
});
|
||||
expect(fixture.queueCheckFor).toHaveBeenCalledWith(path);
|
||||
});
|
||||
|
||||
it("logs a failed revision deletion after disposal", async () => {
|
||||
let finishDeletion!: (result: typeof AUTO_MERGED | typeof MISSING_OR_ERROR) => void;
|
||||
const pendingDeletion = new Promise<typeof AUTO_MERGED | typeof MISSING_OR_ERROR>((resolve) => {
|
||||
finishDeletion = resolve;
|
||||
});
|
||||
const sink = vi.fn();
|
||||
const addLog = bindableFunction<(message: unknown) => void>("addLog");
|
||||
addLog.setHandler(sink);
|
||||
const fixture = createOperations(undefined, {
|
||||
log: createInstanceLogFunction("Conflict shutdown", { addLog } as never),
|
||||
});
|
||||
fixture.dialogueResult.value = "2-right";
|
||||
fixture.getDBEntry.mockResolvedValue({ _rev: "2-left", _conflicts: ["2-right"] });
|
||||
fixture.resolveByDeletingRevision.mockReturnValue(pendingDeletion);
|
||||
|
||||
const resolving = fixture.operations.resolveByUserInteraction(path, conflict);
|
||||
await vi.waitFor(() => expect(fixture.resolveByDeletingRevision).toHaveBeenCalledOnce());
|
||||
const logsBeforeDisposal = sink.mock.calls.length;
|
||||
|
||||
fixture.abort();
|
||||
finishDeletion(MISSING_OR_ERROR);
|
||||
|
||||
await expect(resolving).resolves.toBe(false);
|
||||
expect(sink).toHaveBeenCalledTimes(logsBeforeDisposal + 1);
|
||||
expect(sink).toHaveBeenCalledWith(
|
||||
expect.stringContaining(`Merge: Something went wrong: ${path}`),
|
||||
expect.anything(),
|
||||
expect.anything()
|
||||
);
|
||||
expect(fixture.replicateUnattendedByEvent).not.toHaveBeenCalled();
|
||||
expect(fixture.queueCheckFor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("replicates and requeues after revision deletion succeeds following disposal", async () => {
|
||||
let finishDeletion!: (result: typeof AUTO_MERGED | typeof MISSING_OR_ERROR) => void;
|
||||
const pendingDeletion = new Promise<typeof AUTO_MERGED | typeof MISSING_OR_ERROR>((resolve) => {
|
||||
finishDeletion = resolve;
|
||||
});
|
||||
const sink = vi.fn();
|
||||
const addLog = bindableFunction<(message: unknown) => void>("addLog");
|
||||
addLog.setHandler(sink);
|
||||
const fixture = createOperations(undefined, {
|
||||
log: createInstanceLogFunction("Conflict shutdown", { addLog } as never),
|
||||
});
|
||||
fixture.dialogueResult.value = "2-right";
|
||||
fixture.getDBEntry.mockResolvedValue({ _rev: "2-left", _conflicts: ["2-right"] });
|
||||
fixture.resolveByDeletingRevision.mockReturnValue(pendingDeletion);
|
||||
fixture.currentSettings.mockReturnValue({ syncAfterMerge: true });
|
||||
|
||||
const resolving = fixture.operations.resolveByUserInteraction(path, conflict);
|
||||
await vi.waitFor(() => expect(fixture.resolveByDeletingRevision).toHaveBeenCalledOnce());
|
||||
const logsBeforeDisposal = sink.mock.calls.length;
|
||||
|
||||
fixture.abort();
|
||||
finishDeletion(AUTO_MERGED);
|
||||
|
||||
await expect(resolving).resolves.toBe(false);
|
||||
expect(sink).toHaveBeenCalledTimes(logsBeforeDisposal);
|
||||
expect(fixture.replicateUnattendedByEvent).toHaveBeenCalledWith({
|
||||
trigger: "merge",
|
||||
interaction: expect.anything(),
|
||||
});
|
||||
expect(fixture.queueCheckFor).toHaveBeenCalledWith(path);
|
||||
});
|
||||
|
||||
it("logs a failed conflict inspection but does not refresh UI after disposal", async () => {
|
||||
let rejectInspection!: (reason: unknown) => void;
|
||||
const pendingInspection = new Promise<string[]>((_resolve, reject) => {
|
||||
rejectInspection = reject;
|
||||
});
|
||||
const sink = vi.fn();
|
||||
const addLog = bindableFunction<(message: unknown) => void>("addLog");
|
||||
addLog.setHandler(sink);
|
||||
const fixture = createOperations(undefined, {
|
||||
log: createInstanceLogFunction("Conflict shutdown", { addLog } as never),
|
||||
});
|
||||
fixture.getConflictedRevs.mockReturnValue(pendingInspection);
|
||||
const emitEvent = vi.spyOn(fixture.context.events, "emitEvent");
|
||||
|
||||
const refreshing = fixture.operations.refreshConflictState(path);
|
||||
await vi.waitFor(() => expect(fixture.getConflictedRevs).toHaveBeenCalledOnce());
|
||||
const logsBeforeDisposal = sink.mock.calls.length;
|
||||
|
||||
fixture.abort();
|
||||
rejectInspection(new Error("database unavailable"));
|
||||
|
||||
await expect(refreshing).resolves.toBeUndefined();
|
||||
expect(sink).toHaveBeenCalledTimes(logsBeforeDisposal + 2);
|
||||
expect(sink).toHaveBeenCalledWith(
|
||||
expect.stringContaining(`Could not inspect the conflict state of ${path}`),
|
||||
expect.anything(),
|
||||
expect.anything()
|
||||
);
|
||||
expect(sink).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: "database unavailable" }),
|
||||
expect.anything(),
|
||||
expect.anything()
|
||||
);
|
||||
expect(emitEvent).not.toHaveBeenCalledWith(EVENT_ON_UNRESOLVED_ERROR);
|
||||
});
|
||||
|
||||
it("does not emit a successful conflict refresh after disposal", async () => {
|
||||
let finishInspection!: (revisions: string[]) => void;
|
||||
const pendingInspection = new Promise<string[]>((resolve) => {
|
||||
finishInspection = resolve;
|
||||
});
|
||||
const sink = vi.fn();
|
||||
const addLog = bindableFunction<(message: unknown) => void>("addLog");
|
||||
addLog.setHandler(sink);
|
||||
const fixture = createOperations(undefined, {
|
||||
log: createInstanceLogFunction("Conflict shutdown", { addLog } as never),
|
||||
});
|
||||
fixture.getConflictedRevs.mockReturnValue(pendingInspection);
|
||||
const emitEvent = vi.spyOn(fixture.context.events, "emitEvent");
|
||||
|
||||
const refreshing = fixture.operations.refreshConflictState(path);
|
||||
await vi.waitFor(() => expect(fixture.getConflictedRevs).toHaveBeenCalledOnce());
|
||||
const logsBeforeDisposal = sink.mock.calls.length;
|
||||
|
||||
fixture.abort();
|
||||
finishInspection(["2-right"]);
|
||||
|
||||
await expect(refreshing).resolves.toBeUndefined();
|
||||
expect(sink).toHaveBeenCalledTimes(logsBeforeDisposal);
|
||||
expect(emitEvent).not.toHaveBeenCalledWith(EVENT_ON_UNRESOLVED_ERROR);
|
||||
});
|
||||
|
||||
it("returns no active conflict messages when inspection settles after disposal", async () => {
|
||||
let finishInspection!: (revisions: string[]) => void;
|
||||
const pendingInspection = new Promise<string[]>((resolve) => {
|
||||
finishInspection = resolve;
|
||||
});
|
||||
const fixture = createOperations(undefined, {
|
||||
log: vi.fn(),
|
||||
});
|
||||
fixture.getConflictedRevs.mockReturnValue(pendingInspection);
|
||||
|
||||
const messages = fixture.operations.getActiveConflictMessages();
|
||||
await vi.waitFor(() => expect(fixture.getConflictedRevs).toHaveBeenCalledOnce());
|
||||
fixture.abort();
|
||||
finishInspection(["2-right"]);
|
||||
|
||||
await expect(messages).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("does not inspect or publish conflict state when called after disposal", async () => {
|
||||
const fixture = createOperations();
|
||||
const emit = vi.spyOn(fixture.context.events, "emitEvent");
|
||||
fixture.abort();
|
||||
emit.mockClear();
|
||||
|
||||
await expect(fixture.operations.getActiveConflictMessages()).resolves.toEqual([]);
|
||||
await fixture.operations.refreshConflictState(path);
|
||||
|
||||
expect(fixture.getConflictedRevs).not.toHaveBeenCalled();
|
||||
expect(fixture.getActiveFilePath).not.toHaveBeenCalled();
|
||||
expect(fixture.log).not.toHaveBeenCalled();
|
||||
expect(emit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not requeue a changed comparison when its conflict refresh finishes after disposal", async () => {
|
||||
let finishInspection!: (revisions: string[]) => void;
|
||||
const pendingInspection = new Promise<string[]>((resolve) => {
|
||||
finishInspection = resolve;
|
||||
});
|
||||
const sink = vi.fn();
|
||||
const addLog = bindableFunction<(message: unknown) => void>("addLog");
|
||||
addLog.setHandler(sink);
|
||||
const fixture = createOperations(undefined, {
|
||||
log: createInstanceLogFunction("Conflict shutdown", { addLog } as never),
|
||||
});
|
||||
fixture.dialogueResult.value = "2-right";
|
||||
fixture.getDBEntry.mockResolvedValue({ _rev: "3-new-left", _conflicts: ["2-right"] });
|
||||
fixture.getConflictedRevs.mockReturnValue(pendingInspection);
|
||||
const emit = vi.spyOn(fixture.context.events, "emitEvent");
|
||||
|
||||
const resolving = fixture.operations.resolveByUserInteraction(path, conflict);
|
||||
await vi.waitFor(() => expect(fixture.getConflictedRevs).toHaveBeenCalledOnce());
|
||||
const logsBeforeDisposal = sink.mock.calls.length;
|
||||
fixture.abort();
|
||||
emit.mockClear();
|
||||
finishInspection(["2-right"]);
|
||||
|
||||
await expect(resolving).resolves.toBe(false);
|
||||
expect(sink).toHaveBeenCalledTimes(logsBeforeDisposal);
|
||||
expect(emit).not.toHaveBeenCalled();
|
||||
expect(fixture.storeContent).not.toHaveBeenCalled();
|
||||
expect(fixture.resolveByDeletingRevision).not.toHaveBeenCalled();
|
||||
expect(fixture.queueCheckFor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([AUTO_MERGED, MISSING_OR_ERROR] as const)(
|
||||
"continues concatenated resolution according to deletion result after disposal (%s)",
|
||||
async (result) => {
|
||||
let finishDeletion!: (value: typeof AUTO_MERGED | typeof MISSING_OR_ERROR) => void;
|
||||
const pendingDeletion = new Promise<typeof AUTO_MERGED | typeof MISSING_OR_ERROR>((resolve) => {
|
||||
finishDeletion = resolve;
|
||||
});
|
||||
const sink = vi.fn();
|
||||
const addLog = bindableFunction<(message: unknown) => void>("addLog");
|
||||
addLog.setHandler(sink);
|
||||
const fixture = createOperations(undefined, {
|
||||
log: createInstanceLogFunction("Conflict shutdown", { addLog } as never),
|
||||
});
|
||||
fixture.dialogueResult.value = LEAVE_TO_SUBSEQUENT;
|
||||
fixture.getDBEntry.mockResolvedValue({ _rev: "2-left", _conflicts: ["2-right"] });
|
||||
fixture.resolveByDeletingRevision.mockReturnValue(pendingDeletion);
|
||||
fixture.currentSettings.mockReturnValue({ syncAfterMerge: true });
|
||||
|
||||
const resolving = fixture.operations.resolveByUserInteraction(path, conflict);
|
||||
await vi.waitFor(() => expect(fixture.resolveByDeletingRevision).toHaveBeenCalledOnce());
|
||||
expect(fixture.storeContent).toHaveBeenCalledOnce();
|
||||
const logsBeforeDisposal = sink.mock.calls.length;
|
||||
fixture.abort();
|
||||
finishDeletion(result);
|
||||
|
||||
await expect(resolving).resolves.toBe(false);
|
||||
expect(fixture.resolveByDeletingRevision).toHaveBeenCalledTimes(1);
|
||||
if (result === MISSING_OR_ERROR) {
|
||||
expect(sink).toHaveBeenCalledTimes(logsBeforeDisposal + 1);
|
||||
expect(sink).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Concatenated saved, but cannot delete conflicted revisions:"),
|
||||
expect.anything(),
|
||||
expect.anything()
|
||||
);
|
||||
expect(fixture.replicateUnattendedByEvent).not.toHaveBeenCalled();
|
||||
expect(fixture.queueCheckFor).not.toHaveBeenCalled();
|
||||
} else {
|
||||
expect(sink).toHaveBeenCalledTimes(logsBeforeDisposal);
|
||||
expect(fixture.replicateUnattendedByEvent).toHaveBeenCalledWith({
|
||||
trigger: "merge",
|
||||
interaction: expect.anything(),
|
||||
});
|
||||
expect(fixture.queueCheckFor).toHaveBeenCalledWith(path);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it("requeues after in-flight replication completes following disposal", async () => {
|
||||
let finishReplication!: (result: { status: "completed" }) => void;
|
||||
const pendingReplication = new Promise<{ status: "completed" }>((resolve) => {
|
||||
finishReplication = resolve;
|
||||
});
|
||||
const fixture = createOperations();
|
||||
fixture.dialogueResult.value = "2-right";
|
||||
fixture.getDBEntry.mockResolvedValue({ _rev: "2-left", _conflicts: ["2-right"] });
|
||||
fixture.currentSettings.mockReturnValue({ syncAfterMerge: true });
|
||||
fixture.replicateUnattendedByEvent.mockReturnValue(pendingReplication);
|
||||
|
||||
const resolving = fixture.operations.resolveByUserInteraction(path, conflict);
|
||||
await vi.waitFor(() => expect(fixture.replicateUnattendedByEvent).toHaveBeenCalledOnce());
|
||||
fixture.abort();
|
||||
finishReplication({ status: "completed" });
|
||||
|
||||
await expect(resolving).resolves.toBe(false);
|
||||
expect(fixture.resolveByDeletingRevision).toHaveBeenCalledOnce();
|
||||
expect(fixture.replicateUnattendedByEvent).toHaveBeenCalledTimes(1);
|
||||
expect(fixture.queueCheckFor).toHaveBeenCalledWith(path);
|
||||
});
|
||||
|
||||
it("replaces an active same-file dialogue with only the newest waiting request", async () => {
|
||||
const fixture = createDialogueConcurrencyHarness();
|
||||
|
||||
@@ -233,6 +681,40 @@ describe("interactive conflict resolution operations", () => {
|
||||
await Promise.all([first, waiting]);
|
||||
});
|
||||
|
||||
it("keeps another instance's active and waiting dialogues when disposal is repeated", async () => {
|
||||
const oldInstance = createDialogueConcurrencyHarness();
|
||||
const currentInstance = createDialogueConcurrencyHarness();
|
||||
|
||||
const oldActive = oldInstance.operations.resolveByUserInteraction(path, conflict);
|
||||
await vi.waitFor(() => expect(oldInstance.dialogues).toHaveLength(1));
|
||||
const oldWaiting = oldInstance.operations.resolveByUserInteraction(
|
||||
"old-waiting.md" as FilePathWithPrefix,
|
||||
conflict
|
||||
);
|
||||
|
||||
const currentActive = currentInstance.operations.resolveByUserInteraction(path, conflict);
|
||||
await vi.waitFor(() => expect(currentInstance.dialogues).toHaveLength(1));
|
||||
const currentWaiting = currentInstance.operations.resolveByUserInteraction(
|
||||
"current-waiting.md" as FilePathWithPrefix,
|
||||
conflict
|
||||
);
|
||||
await Promise.resolve();
|
||||
|
||||
oldInstance.abort();
|
||||
oldInstance.abort();
|
||||
|
||||
await expect(Promise.all([oldActive, oldWaiting])).resolves.toEqual([false, false]);
|
||||
expect(oldInstance.dialogues).toHaveLength(1);
|
||||
expect(currentInstance.dialogues).toHaveLength(1);
|
||||
expect(currentInstance.emitEvent).not.toHaveBeenCalledWith(EVENT_CONFLICT_CANCELLED, path);
|
||||
|
||||
currentInstance.dialogues[0].finish(CANCELLED);
|
||||
await vi.waitFor(() => expect(currentInstance.dialogues).toHaveLength(2));
|
||||
expect(currentInstance.dialogues[1].filename).toBe("current-waiting.md");
|
||||
currentInstance.dialogues[1].finish(CANCELLED);
|
||||
await Promise.all([currentActive, currentWaiting]);
|
||||
});
|
||||
|
||||
it("does not reopen an unchanged conflict after the user chooses Not now", async () => {
|
||||
const { constructed, operations } = createOperations();
|
||||
|
||||
@@ -770,6 +1252,7 @@ describe("interactive conflict resolution feature composition", () => {
|
||||
|
||||
expect(completedOnUnload).toBe(true);
|
||||
expect(dialogues).toHaveLength(1);
|
||||
expect(fixture.getConflictedRevs).not.toHaveBeenCalled();
|
||||
await fixture.handlers.onUnload?.();
|
||||
});
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ import type { LiveSyncLocalDB } from "@vrtmrz/livesync-commonlib/compat/pouchdb/
|
||||
import type { LiveSyncEventHub } from "@vrtmrz/livesync-commonlib/context";
|
||||
import { fireAndForget } from "octagonal-wheels/promises";
|
||||
import { withLock } from "octagonal-wheels/concurrency/lock";
|
||||
import { withCancellation } from "octagonal-wheels/concurrency/cancellation";
|
||||
import { withScope } from "octagonal-wheels/resource/scope";
|
||||
import { NO_INTERACTION } from "@vrtmrz/livesync-commonlib/replication";
|
||||
import { displayRev } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import { EVENT_CONFLICT_CANCELLED, EVENT_ON_UNRESOLVED_ERROR } from "@/common/events.ts";
|
||||
@@ -38,6 +40,7 @@ import {
|
||||
|
||||
export interface InteractiveConflictResolutionOperationsDependencies {
|
||||
readonly events: Pick<LiveSyncEventHub, "emitEvent">;
|
||||
readonly signal: AbortSignal;
|
||||
readonly databaseFileAccess: Pick<DatabaseFileAccess, "getConflictedRevs" | "storeContent">;
|
||||
readonly localDatabase: () => Pick<LiveSyncLocalDB, "getDBEntry" | "findAllDocs">;
|
||||
readonly confirm: Pick<Confirm, "askSelectString" | "askInPopup">;
|
||||
@@ -52,11 +55,11 @@ export interface InteractiveConflictResolutionOperationsDependencies {
|
||||
}
|
||||
|
||||
export interface InteractiveConflictResolutionOperations {
|
||||
readonly dispose: () => void;
|
||||
readonly invalidateWaitingResolution: (filename: FilePathWithPrefix) => void;
|
||||
readonly getActiveConflictMessages: () => Promise<string[]>;
|
||||
readonly refreshConflictState: (filename: FilePathWithPrefix) => Promise<void>;
|
||||
readonly requestConflictResolution: (filename: FilePathWithPrefix) => Promise<void>;
|
||||
/** Returns false if the requested conflict check was cancelled. */
|
||||
readonly requestConflictResolution: (filename: FilePathWithPrefix) => Promise<boolean>;
|
||||
readonly resolveByUserInteraction: (
|
||||
filename: FilePathWithPrefix,
|
||||
conflictCheckResult: diff_result
|
||||
@@ -76,9 +79,18 @@ export function createInteractiveConflictResolutionOperations(
|
||||
const latestRequestByFilename = new Map<FilePathWithPrefix, number>();
|
||||
let nextRequestId = 0;
|
||||
let activeDialogue: { filename: FilePathWithPrefix; dialogue: ConflictResolveDialogue } | undefined;
|
||||
let disposed = false;
|
||||
const clearPendingState = (): void => {
|
||||
latestRequestByFilename.clear();
|
||||
postponedConflictEpisodes.clear();
|
||||
};
|
||||
if (dependencies.signal.aborted) {
|
||||
clearPendingState();
|
||||
} else {
|
||||
dependencies.signal.addEventListener("abort", clearPendingState, { once: true });
|
||||
}
|
||||
|
||||
const invalidateWaitingResolution = (filename: FilePathWithPrefix): void => {
|
||||
if (dependencies.signal.aborted) return;
|
||||
// An active dialogue consumes this event itself. Removing its request
|
||||
// here would also remove a same-path replacement which emitted the
|
||||
// event to close that active dialogue. A non-active request is stale
|
||||
@@ -87,21 +99,11 @@ export function createInteractiveConflictResolutionOperations(
|
||||
latestRequestByFilename.delete(filename);
|
||||
};
|
||||
|
||||
const dispose = (): void => {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
latestRequestByFilename.clear();
|
||||
postponedConflictEpisodes.clear();
|
||||
const activeFilename = activeDialogue?.filename;
|
||||
if (activeFilename !== undefined) {
|
||||
dependencies.events.emitEvent(EVENT_CONFLICT_CANCELLED, activeFilename);
|
||||
activeDialogue = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const getConflictVersionCount = async (filename: FilePathWithPrefix): Promise<number | undefined> => {
|
||||
if (dependencies.signal.aborted) return undefined;
|
||||
try {
|
||||
const conflictCount = (await dependencies.databaseFileAccess.getConflictedRevs(filename)).length;
|
||||
if (dependencies.signal.aborted) return undefined;
|
||||
return conflictCount === 0 ? 0 : conflictCount + 1;
|
||||
} catch (error) {
|
||||
dependencies.log(`Could not inspect the conflict state of ${filename}`, LOG_LEVEL_VERBOSE);
|
||||
@@ -111,9 +113,11 @@ export function createInteractiveConflictResolutionOperations(
|
||||
};
|
||||
|
||||
const getActiveConflictMessages = async (): Promise<string[]> => {
|
||||
if (dependencies.signal.aborted) return [];
|
||||
const filename = dependencies.vault.getActiveFilePath();
|
||||
if (!filename) return [];
|
||||
const versionCount = await getConflictVersionCount(filename);
|
||||
if (dependencies.signal.aborted) return [];
|
||||
if (versionCount === 0) {
|
||||
postponedConflictEpisodes.delete(filename);
|
||||
return [];
|
||||
@@ -132,39 +136,50 @@ export function createInteractiveConflictResolutionOperations(
|
||||
};
|
||||
|
||||
const refreshConflictState = async (filename: FilePathWithPrefix): Promise<void> => {
|
||||
if ((await getConflictVersionCount(filename)) === 0) {
|
||||
if (dependencies.signal.aborted) return;
|
||||
const versionCount = await getConflictVersionCount(filename);
|
||||
if (dependencies.signal.aborted) return;
|
||||
if (versionCount === 0) {
|
||||
postponedConflictEpisodes.delete(filename);
|
||||
}
|
||||
dependencies.events.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
|
||||
};
|
||||
|
||||
const requestConflictResolution = async (filename: FilePathWithPrefix): Promise<void> => {
|
||||
const requestConflictResolution = async (filename: FilePathWithPrefix): Promise<boolean> => {
|
||||
if (dependencies.signal.aborted) return false;
|
||||
postponedConflictEpisodes.delete(filename);
|
||||
dependencies.events.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
|
||||
await dependencies.conflict.queueCheckFor(filename);
|
||||
await dependencies.conflict.ensureAllProcessed();
|
||||
if (dependencies.signal.aborted) return false;
|
||||
return await dependencies.conflict.ensureAllProcessed();
|
||||
};
|
||||
|
||||
const resolveByUserInteraction = async (
|
||||
filename: FilePathWithPrefix,
|
||||
conflictCheckResult: diff_result
|
||||
): Promise<boolean> => {
|
||||
if (disposed) return false;
|
||||
const requestId = ++nextRequestId;
|
||||
if (activeDialogue?.filename === filename) {
|
||||
dependencies.events.emitEvent(EVENT_CONFLICT_CANCELLED, filename);
|
||||
activeDialogue = undefined;
|
||||
}
|
||||
latestRequestByFilename.set(filename, requestId);
|
||||
|
||||
// UI for resolving different files should proceed one-by-one. A newer
|
||||
// request for the active file replaces its dialogue instead of waiting
|
||||
// behind a comparison which is already stale.
|
||||
return await withLock(dialogueSerialisationKey, async () => {
|
||||
if (disposed || latestRequestByFilename.get(filename) !== requestId) {
|
||||
return false;
|
||||
if (dependencies.signal.aborted) return false;
|
||||
return await withScope(undefined, async (request) => {
|
||||
if (dependencies.signal.aborted) return false;
|
||||
const requestId = ++nextRequestId;
|
||||
if (activeDialogue?.filename === filename) {
|
||||
dependencies.events.emitEvent(EVENT_CONFLICT_CANCELLED, filename);
|
||||
activeDialogue = undefined;
|
||||
}
|
||||
try {
|
||||
latestRequestByFilename.set(filename, requestId);
|
||||
request.defer(() => {
|
||||
if (latestRequestByFilename.get(filename) === requestId) {
|
||||
latestRequestByFilename.delete(filename);
|
||||
}
|
||||
});
|
||||
|
||||
// UI for resolving different files should proceed one-by-one. A newer
|
||||
// request for the active file replaces its dialogue instead of waiting
|
||||
// behind a comparison which is already stale.
|
||||
return await withLock(dialogueSerialisationKey, async () => {
|
||||
if (dependencies.signal.aborted || latestRequestByFilename.get(filename) !== requestId) {
|
||||
return false;
|
||||
}
|
||||
if (postponedConflictEpisodes.has(filename)) {
|
||||
dependencies.log(`Merge: Postponed ${filename}`, LOG_LEVEL_VERBOSE);
|
||||
dependencies.events.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
|
||||
@@ -175,13 +190,24 @@ export function createInteractiveConflictResolutionOperations(
|
||||
activeDialogue = { filename, dialogue };
|
||||
let selected: MergeDialogResult;
|
||||
try {
|
||||
dialogue.open();
|
||||
selected = await dialogue.waitForResult();
|
||||
selected = await withCancellation(dialogue, { signal: dependencies.signal }, async (waiting) => {
|
||||
const cancelDialogue = (): void => {
|
||||
if (activeDialogue?.dialogue === dialogue) {
|
||||
dependencies.events.emitEvent(EVENT_CONFLICT_CANCELLED, filename);
|
||||
}
|
||||
};
|
||||
waiting.signal.addEventListener("abort", cancelDialogue, { once: true });
|
||||
waiting.defer(() => waiting.signal.removeEventListener("abort", cancelDialogue));
|
||||
dialogue.open();
|
||||
if (waiting.isCancelled()) cancelDialogue();
|
||||
return await dialogue.waitForResult();
|
||||
});
|
||||
} finally {
|
||||
if (activeDialogue?.dialogue === dialogue) {
|
||||
activeDialogue = undefined;
|
||||
}
|
||||
}
|
||||
if (dependencies.signal.aborted) return false;
|
||||
if (selected === POSTPONED) {
|
||||
postponedConflictEpisodes.add(filename);
|
||||
dependencies.events.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
|
||||
@@ -203,6 +229,7 @@ export function createInteractiveConflictResolutionOperations(
|
||||
if (!testDoc._conflicts || testDoc._conflicts.length === 0) {
|
||||
dependencies.log(`Merge: Nothing to do ${filename}`, LOG_LEVEL_VERBOSE);
|
||||
await refreshConflictState(filename);
|
||||
if (dependencies.signal.aborted) return false;
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
@@ -214,6 +241,7 @@ export function createInteractiveConflictResolutionOperations(
|
||||
LOG_LEVEL_INFO
|
||||
);
|
||||
await refreshConflictState(filename);
|
||||
if (dependencies.signal.aborted) return false;
|
||||
await dependencies.conflict.queueCheckFor(filename);
|
||||
return false;
|
||||
}
|
||||
@@ -224,15 +252,18 @@ export function createInteractiveConflictResolutionOperations(
|
||||
// Create a new file by concatenating both conflicted revisions.
|
||||
const p = conflictCheckResult.diff.map((e) => e[1]).join("");
|
||||
const delRev = conflictCheckResult.right.rev;
|
||||
if (!(await dependencies.databaseFileAccess.storeContent(filename, p))) {
|
||||
const contentStored = await dependencies.databaseFileAccess.storeContent(filename, p);
|
||||
if (!contentStored) {
|
||||
dependencies.log(`Concatenated content cannot be stored:${filename}`, LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
// 2. As usual, delete the conflicted revision and if there are no conflicts, write the resolved content to the storage.
|
||||
if (
|
||||
(await dependencies.conflict.resolveByDeletingRevision(filename, delRev, "UI Concatenated")) ==
|
||||
MISSING_OR_ERROR
|
||||
) {
|
||||
const deletionResult = await dependencies.conflict.resolveByDeletingRevision(
|
||||
filename,
|
||||
delRev,
|
||||
"UI Concatenated"
|
||||
);
|
||||
if (deletionResult == MISSING_OR_ERROR) {
|
||||
dependencies.log(
|
||||
`Concatenated saved, but cannot delete conflicted revisions: ${filename}, (${displayRev(delRev)})`,
|
||||
LOG_LEVEL_NOTICE
|
||||
@@ -244,10 +275,12 @@ export function createInteractiveConflictResolutionOperations(
|
||||
(toDelete === conflictCheckResult.left.rev || toDelete === conflictCheckResult.right.rev)
|
||||
) {
|
||||
// Select one of the conflicted revision to delete.
|
||||
if (
|
||||
(await dependencies.conflict.resolveByDeletingRevision(filename, toDelete, "UI Selected")) ==
|
||||
MISSING_OR_ERROR
|
||||
) {
|
||||
const deletionResult = await dependencies.conflict.resolveByDeletingRevision(
|
||||
filename,
|
||||
toDelete,
|
||||
"UI Selected"
|
||||
);
|
||||
if (deletionResult == MISSING_OR_ERROR) {
|
||||
dependencies.log(`Merge: Something went wrong: ${filename}, (${toDelete})`, LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
@@ -270,17 +303,15 @@ export function createInteractiveConflictResolutionOperations(
|
||||
// And, check it again.
|
||||
await dependencies.conflict.queueCheckFor(filename);
|
||||
return false;
|
||||
} finally {
|
||||
if (latestRequestByFilename.get(filename) === requestId) {
|
||||
latestRequestByFilename.delete(filename);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const pickFileForResolve = async (notifyIfEmpty = true): Promise<boolean> => {
|
||||
if (dependencies.signal.aborted) return false;
|
||||
const notes: { id: DocumentID; path: FilePathWithPrefix; dispPath: string; mtime: number }[] = [];
|
||||
for await (const doc of dependencies.localDatabase().findAllDocs({ conflicts: true })) {
|
||||
if (dependencies.signal.aborted) return false;
|
||||
if (!("_conflicts" in doc)) continue;
|
||||
notes.push({
|
||||
id: doc._id,
|
||||
@@ -289,6 +320,7 @@ export function createInteractiveConflictResolutionOperations(
|
||||
mtime: doc.mtime,
|
||||
});
|
||||
}
|
||||
if (dependencies.signal.aborted) return false;
|
||||
notes.sort((a, b) => b.mtime - a.mtime);
|
||||
const notesList = notes.map((e) => e.dispPath);
|
||||
if (notesList.length == 0) {
|
||||
@@ -298,6 +330,7 @@ export function createInteractiveConflictResolutionOperations(
|
||||
return false;
|
||||
}
|
||||
const target = await dependencies.confirm.askSelectString("File to resolve conflict", notesList);
|
||||
if (dependencies.signal.aborted) return false;
|
||||
if (target) {
|
||||
const targetItem = notes.find((e) => e.dispPath == target)!;
|
||||
await requestConflictResolution(targetItem.path);
|
||||
@@ -308,19 +341,22 @@ export function createInteractiveConflictResolutionOperations(
|
||||
|
||||
const allConflictCheck = async (): Promise<void> => {
|
||||
let notifyIfEmpty = true;
|
||||
while (await pickFileForResolve(notifyIfEmpty)) {
|
||||
while (!dependencies.signal.aborted && (await pickFileForResolve(notifyIfEmpty))) {
|
||||
notifyIfEmpty = false;
|
||||
}
|
||||
};
|
||||
|
||||
const scanStartupIssues = async (): Promise<boolean> => {
|
||||
if (dependencies.signal.aborted) return false;
|
||||
const notes: { path: string; mtime: number }[] = [];
|
||||
dependencies.log(`Checking conflicted files`, LOG_LEVEL_VERBOSE);
|
||||
try {
|
||||
for await (const doc of dependencies.localDatabase().findAllDocs({ conflicts: true })) {
|
||||
if (dependencies.signal.aborted) return false;
|
||||
if (!("_conflicts" in doc)) continue;
|
||||
notes.push({ path: dependencies.path.getPath(doc), mtime: doc.mtime });
|
||||
}
|
||||
if (dependencies.signal.aborted) return false;
|
||||
if (notes.length > 0) {
|
||||
dependencies.confirm.askInPopup(
|
||||
`conflicting-detected-on-safety`,
|
||||
@@ -351,7 +387,6 @@ export function createInteractiveConflictResolutionOperations(
|
||||
};
|
||||
|
||||
return {
|
||||
dispose,
|
||||
invalidateWaitingResolution,
|
||||
getActiveConflictMessages,
|
||||
refreshConflictState,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { getStoragePathComparisonKey, type FilePath, type PathComparisonKey } from "@vrtmrz/livesync-commonlib/storage";
|
||||
|
||||
/**
|
||||
* Keeps storage from being changed through a name which the file system resolves but no directory lists.
|
||||
*
|
||||
@@ -20,7 +22,13 @@ export interface PathAliasGuardHost {
|
||||
/** How long the names which a directory listed are taken as still listed. A name which is missed is listed afresh. */
|
||||
const LISTING_LIFETIME_MS = 1_000;
|
||||
|
||||
const foldName = (name: string) => name.normalize("NFC").toLowerCase();
|
||||
// Host alias checks retain their fixed NFC and case-insensitive policy in both identity modes.
|
||||
// Original host names keep the host vocabulary; comparison does not apply rooted-storage input validation.
|
||||
const foldName = (name: string) =>
|
||||
getStoragePathComparisonKey(name as FilePath, {
|
||||
pathNormalizationVersion: 1,
|
||||
handleFilenameCaseSensitive: false,
|
||||
});
|
||||
|
||||
const nameOf = (entry: string) => {
|
||||
const segments = entry.split("/").filter((segment) => segment !== "");
|
||||
@@ -28,14 +36,14 @@ const nameOf = (entry: string) => {
|
||||
};
|
||||
|
||||
export class PathAliasGuard {
|
||||
private readonly listings = new Map<string, { names: Set<string>; listedAt: number }>();
|
||||
private readonly listings = new Map<string, { names: Set<PathComparisonKey<"storage">>; listedAt: number }>();
|
||||
|
||||
constructor(
|
||||
private readonly host: PathAliasGuardHost,
|
||||
private readonly now: () => number = Date.now
|
||||
) {}
|
||||
|
||||
private recentListing(directory: string): Set<string> | undefined {
|
||||
private recentListing(directory: string): Set<PathComparisonKey<"storage">> | undefined {
|
||||
const listing = this.listings.get(directory);
|
||||
if (!listing) return undefined;
|
||||
if (this.now() - listing.listedAt <= LISTING_LIFETIME_MS) return listing.names;
|
||||
@@ -43,7 +51,7 @@ export class PathAliasGuard {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private async listAfresh(directory: string): Promise<Set<string>> {
|
||||
private async listAfresh(directory: string): Promise<Set<PathComparisonKey<"storage">>> {
|
||||
const names = new Set((await this.host.list(directory)).map((entry) => foldName(nameOf(entry))));
|
||||
this.listings.set(directory, { names, listedAt: this.now() });
|
||||
return names;
|
||||
|
||||
@@ -15,7 +15,7 @@ import type { App, TFile } from "obsidian";
|
||||
import { afterAll, describe, expect, it, vi } from "vitest";
|
||||
import { ObsidianStorageAdapter } from "./ObsidianStorageAdapter";
|
||||
import { ObsidianVaultAdapter } from "./ObsidianVaultAdapter";
|
||||
import { createPathAliasGuard } from "./pathAliasGuard";
|
||||
import { createPathAliasGuard, PathAliasGuard } from "./pathAliasGuard";
|
||||
|
||||
const REFUSAL = "Refusing a path which reaches an existing entry by a name its folder does not list.";
|
||||
const CONFIG_DIRECTORY = ".obsidian";
|
||||
@@ -141,6 +141,30 @@ describe("paths which resolve to an existing entry under an unlisted name", () =
|
||||
expect(calls.trashFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows NFC and case equivalents of names exposed by the host listing", async () => {
|
||||
const folder = "Café".normalize("NFD");
|
||||
const guard = new PathAliasGuard({
|
||||
isIndexed: () => false,
|
||||
exists: async () => true,
|
||||
list: async (path) => (path === "" ? [folder] : [`${folder}/Note.md`]),
|
||||
});
|
||||
|
||||
await expect(guard.assertNoAlias("CAFÉ/note.md")).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it.each(["x:cache", "host\\name"])(
|
||||
"allows a listed target beside the unrelated host-listed name %s",
|
||||
async (unrelatedName) => {
|
||||
const guard = new PathAliasGuard({
|
||||
isIndexed: () => false,
|
||||
exists: async () => true,
|
||||
list: async () => [unrelatedName, "note.md"],
|
||||
});
|
||||
|
||||
await expect(guard.assertNoAlias("note.md")).resolves.toBeUndefined();
|
||||
}
|
||||
);
|
||||
|
||||
it("allows listed names, case-only differences, indexed paths, and new files", async () => {
|
||||
const { app, calls } = fakeApp();
|
||||
const adapter = new ObsidianStorageAdapter(app);
|
||||
|
||||
Reference in new issue
Block a user