Compare commits

..

14 Commits

Author SHA1 Message Date
vorotamoroz b757308bdf test: share Obsidian E2E session infrastructure 2026-07-10 12:07:15 +00:00
vorotamoroz 6a9918677f Merge pull request #996 from vrtmrz/0_25_80
releasing 0.25.80
2026-07-07 21:21:34 +09:00
vorotamoroz 2d42b92a89 bump 2026-07-07 12:15:58 +00:00
vorotamoroz a2b794c520 Merge pull request #995 from vrtmrz/fix_conflict_issues
Fix conflict issues
2026-07-07 21:13:00 +09:00
vorotamoroz 6f9446f447 Merge remote-tracking branch 'origin/main' into fix_conflict_issues 2026-07-07 12:11:38 +00:00
vorotamoroz 3f9cd67b1c Merge pull request #992 from vrtmrz/fix_989
fix: enable hidden file sync before overwrite setup
2026-07-07 21:10:09 +09:00
vorotamoroz dbcbf2c5ca fix: document safer conflict preservation 2026-07-07 12:05:40 +00:00
vorotamoroz eed0fca8d3 fix: preserve ambiguous conflict candidates 2026-07-07 12:00:24 +00:00
vorotamoroz 05e031b90b update submodule pointer 2026-07-07 11:31:29 +00:00
vorotamoroz bec767c13f add notes 2026-07-07 11:30:36 +00:00
vorotamoroz 8046a777af fix: refine conflict merge policy 2026-07-07 11:29:51 +00:00
vorotamoroz 0c58b0c513 test: reproduce conflict issue reports 2026-07-07 11:07:09 +00:00
vorotamoroz b66e227a02 update doc
update submodule pointer
2026-07-07 10:54:49 +00:00
vorotamoroz af6df84b5d fix: enable hidden file sync before overwrite setup 2026-07-03 05:09:55 +00:00
22 changed files with 302 additions and 861 deletions
+14
View File
@@ -148,6 +148,20 @@ Hence, the new feature should be implemented as follows:
- **Service Hub** (`src/modules/services/`): Central service registry using dependency injection
- **Common Library** (`src/lib/`): Platform-independent sync logic, shared with other tools
### Conflict Merge Policy
Markdown conflict auto-merge should behave like a conservative three-way merge. The guiding rule is to merge changes when they touch non-overlapping regions, and to keep a manual conflict when the edits overlap semantically.
When in doubt, prefer the safer outcome: preserve data, keep the conflict visible, and ask the user rather than silently discarding content or choosing one side.
- If one side deletes a line and the other side leaves that same line unchanged, treat it as a safe deletion. The deleted line must not be reintroduced into the merged result.
- If one side inserts new content in a different region while the other side deletes an unchanged old region, preserve the insertion and the deletion.
- If one side deletes a line and the other side modifies that same line, keep the conflict for user resolution.
- If both sides insert different content at the same position, keep both insertions in a deterministic order unless the surrounding deletion context indicates that they are competing replacements.
- Avoid resolving conflicts by simply choosing the newest revision unless the user has explicitly selected that behaviour.
This policy is intentionally aligned with the conflict checkboxes and compatibility settings: automatic merge should remove avoidable prompts, but it must not silently choose between overlapping user intentions.
### File Structure Conventions
- **Platform-specific code**: Use `.platform.ts` suffix (replaced with `.obsidian.ts` in production builds via esbuild)
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "obsidian-livesync",
"name": "Self-hosted LiveSync",
"version": "0.25.79",
"version": "0.25.80",
"minAppVersion": "1.7.2",
"description": "Community implementation of self-hosted livesync. Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
"author": "vorotamoroz",
+19 -5
View File
@@ -1,12 +1,12 @@
{
"name": "obsidian-livesync",
"version": "0.25.79",
"version": "0.25.80",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "obsidian-livesync",
"version": "0.25.79",
"version": "0.25.80",
"license": "MIT",
"workspaces": [
"src/apps/cli",
@@ -58,6 +58,7 @@
"@vitest/browser": "^4.1.8",
"@vitest/browser-playwright": "^4.1.8",
"@vitest/coverage-v8": "^4.1.8",
"@vrtmrz/obsidian-test-session": "https://github.com/vrtmrz/fancy-kit/releases/download/consumer-preview-2026-07-10.2/vrtmrz-obsidian-test-session-0.0.0.tgz",
"dotenv-cli": "^11.0.0",
"esbuild": "0.28.1",
"esbuild-plugin-inline-worker": "^0.1.1",
@@ -4944,6 +4945,19 @@
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vrtmrz/obsidian-test-session": {
"version": "0.0.0",
"resolved": "https://github.com/vrtmrz/fancy-kit/releases/download/consumer-preview-2026-07-10.2/vrtmrz-obsidian-test-session-0.0.0.tgz",
"integrity": "sha512-XHWFORR8Q7vQsbKxrj8RBgpyC7DHFw5PSUXkBOKJoHnx9abgCkuQp4gYYa64RO7sOdRx/Xj799JOop+McQSqsg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20"
},
"peerDependencies": {
"playwright": ">=1.50.0"
}
},
"node_modules/@wdio/config": {
"version": "9.27.0",
"resolved": "https://registry.npmjs.org/@wdio/config/-/config-9.27.0.tgz",
@@ -16210,7 +16224,7 @@
},
"src/apps/cli": {
"name": "self-hosted-livesync-cli",
"version": "0.25.79-cli",
"version": "0.25.80-cli",
"dependencies": {
"chokidar": "^4.0.0",
"minimatch": "^10.2.5",
@@ -16236,7 +16250,7 @@
},
"src/apps/webapp": {
"name": "livesync-webapp",
"version": "0.25.79-webapp",
"version": "0.25.80-webapp",
"dependencies": {
"octagonal-wheels": "^0.1.47"
},
@@ -16251,7 +16265,7 @@
}
},
"src/apps/webpeer": {
"version": "0.25.79-webpeer",
"version": "0.25.80-webpeer",
"dependencies": {
"octagonal-wheels": "^0.1.47"
},
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "obsidian-livesync",
"version": "0.25.79",
"version": "0.25.80",
"description": "Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
"main": "main.js",
"type": "module",
@@ -105,6 +105,7 @@
"@vitest/browser": "^4.1.8",
"@vitest/browser-playwright": "^4.1.8",
"@vitest/coverage-v8": "^4.1.8",
"@vrtmrz/obsidian-test-session": "https://github.com/vrtmrz/fancy-kit/releases/download/consumer-preview-2026-07-10.2/vrtmrz-obsidian-test-session-0.0.0.tgz",
"dotenv-cli": "^11.0.0",
"esbuild": "0.28.1",
"esbuild-plugin-inline-worker": "^0.1.1",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "self-hosted-livesync-cli",
"private": true,
"version": "0.25.79-cli",
"version": "0.25.80-cli",
"main": "dist/index.cjs",
"type": "module",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "livesync-webapp",
"private": true,
"version": "0.25.79-webapp",
"version": "0.25.80-webapp",
"type": "module",
"description": "Browser-based Self-hosted LiveSync using FileSystem API",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "webpeer",
"private": true,
"version": "0.25.79-webpeer",
"version": "0.25.80-webpeer",
"type": "module",
"scripts": {
"dev": "vite",
@@ -51,6 +51,7 @@ import { EVENT_SETTING_SAVED, eventHub } from "@/common/events.ts";
import { Semaphore } from "octagonal-wheels/concurrency/semaphore";
import type { LiveSyncCore } from "@/main.ts";
import { tryGetFilePath } from "@lib/common/utils.doc.ts";
import { configureHiddenFileSyncMode } from "./configureHiddenFileSyncMode.ts";
type SyncDirection = "push" | "pull" | "safe" | "pullForce" | "pushForce";
declare global {
@@ -1832,43 +1833,35 @@ ${messageFetch}${messageOverwrite}${messageMerge}
}
async configureHiddenFileSync(mode: keyof OPTIONAL_SYNC_FEATURES) {
if (
mode != "FETCH" &&
mode != "OVERWRITE" &&
mode != "MERGE" &&
mode != "DISABLE" &&
mode != "DISABLE_HIDDEN"
) {
return;
}
if (mode == "DISABLE" || mode == "DISABLE_HIDDEN") {
// await this.core.$allSuspendExtraSync();
await this.core.services.setting.applyPartial(
{
syncInternalFiles: false,
},
true
);
// this.core.settings.syncInternalFiles = false;
// await this.core.saveSettings();
return;
}
this._log("Gathering files for enabling Hidden File Sync", LOG_LEVEL_NOTICE);
if (mode == "FETCH") {
await this.initialiseInternalFileSync("pullForce", true);
} else if (mode == "OVERWRITE") {
await this.initialiseInternalFileSync("pushForce", true);
} else if (mode == "MERGE") {
await this.initialiseInternalFileSync("safe", true);
}
await this.core.services.setting.applyPartial(
{
useAdvancedMode: true,
syncInternalFiles: true,
const result = await configureHiddenFileSyncMode(mode, {
disable: async () => {
// await this.core.$allSuspendExtraSync();
await this.core.services.setting.applyPartial(
{
syncInternalFiles: false,
},
true
);
// this.core.settings.syncInternalFiles = false;
// await this.core.saveSettings();
},
true
);
enable: async () => {
this._log("Gathering files for enabling Hidden File Sync", LOG_LEVEL_NOTICE);
await this.core.services.setting.applyPartial(
{
useAdvancedMode: true,
syncInternalFiles: true,
},
true
);
},
initialise: async (direction) => {
await this.initialiseInternalFileSync(direction, true);
},
});
if (result == "ignored" || result == "disabled") {
return;
}
// this.plugin.settings.useAdvancedMode = true;
// this.plugin.settings.syncInternalFiles = true;
@@ -0,0 +1,45 @@
type HiddenFileSyncMode = "FETCH" | "OVERWRITE" | "MERGE" | "DISABLE" | "DISABLE_HIDDEN";
type HiddenFileSyncDirection = "pullForce" | "pushForce" | "safe";
type ConfigureHiddenFileSyncHandlers = {
disable: () => Promise<void>;
enable: () => Promise<void>;
initialise: (direction: HiddenFileSyncDirection) => Promise<void>;
};
export type ConfigureHiddenFileSyncResult = "ignored" | "disabled" | "enabled";
function getInitialiseDirection(mode: keyof OPTIONAL_SYNC_FEATURES): HiddenFileSyncDirection | false {
if (mode == "FETCH") return "pullForce";
if (mode == "OVERWRITE") return "pushForce";
if (mode == "MERGE") return "safe";
return false;
}
function isDisableMode(mode: keyof OPTIONAL_SYNC_FEATURES): boolean {
return mode == "DISABLE" || mode == "DISABLE_HIDDEN";
}
function isHiddenFileSyncMode(mode: keyof OPTIONAL_SYNC_FEATURES): mode is HiddenFileSyncMode {
return mode == "FETCH" || mode == "OVERWRITE" || mode == "MERGE" || isDisableMode(mode);
}
export async function configureHiddenFileSyncMode(
mode: keyof OPTIONAL_SYNC_FEATURES,
handlers: ConfigureHiddenFileSyncHandlers
): Promise<ConfigureHiddenFileSyncResult> {
if (!isHiddenFileSyncMode(mode)) {
return "ignored";
}
if (isDisableMode(mode)) {
await handlers.disable();
return "disabled";
}
const direction = getInitialiseDirection(mode);
if (direction === false) {
return "ignored";
}
await handlers.enable();
await handlers.initialise(direction);
return "enabled";
}
@@ -0,0 +1,47 @@
import { describe, expect, it, vi } from "vitest";
import { configureHiddenFileSyncMode } from "./configureHiddenFileSyncMode.ts";
describe("configureHiddenFileSyncMode", () => {
it.each([
["FETCH", "pullForce"],
["OVERWRITE", "pushForce"],
["MERGE", "safe"],
] as const)("enables hidden file sync before initialising %s", async (mode, direction) => {
const calls: string[] = [];
const result = await configureHiddenFileSyncMode(mode, {
disable: vi.fn(async () => {
calls.push("disable");
}),
enable: vi.fn(async () => {
calls.push("enable");
}),
initialise: vi.fn(async (actualDirection) => {
calls.push(`init:${actualDirection}`);
}),
});
expect(result).toBe("enabled");
expect(calls).toEqual(["enable", `init:${direction}`]);
});
it.each(["DISABLE", "DISABLE_HIDDEN"] as const)("disables hidden file sync immediately for %s", async (mode) => {
const calls: string[] = [];
const result = await configureHiddenFileSyncMode(mode, {
disable: vi.fn(async () => {
calls.push("disable");
}),
enable: vi.fn(async () => {
calls.push("enable");
}),
initialise: vi.fn(async (direction) => {
calls.push(`init:${direction}`);
}),
});
expect(result).toBe("disabled");
expect(calls).toEqual(["disable"]);
});
});
+1 -1
Submodule src/lib updated: cf9810dec5...a0efb7274e
+3 -1
View File
@@ -2,6 +2,8 @@
This directory contains the experimental real Obsidian end-to-end runner.
The generic application discovery, isolated-vault, plug-in installation, process lifecycle, CLI, CDP, and readiness implementation comes from `@vrtmrz/obsidian-test-session`. The small modules under `runner/` preserve LiveSync's existing imports and supply its plug-in ID and artefact location. LiveSync-specific fixtures, services, settings, workflows, and assertions remain in this repository.
The current smoke runner verifies only the launch path:
1. create a temporary vault,
@@ -18,7 +20,7 @@ The runner does not require Self-hosted LiveSync to expose an E2E-only bridge. R
Obsidian 1.12 stores the global community plug-in switch outside `.obsidian/community-plugins.json`. The smoke runner enables it through `app.plugins.setEnable(true)` after the vault window is available.
Future workflows should use `startObsidianLiveSyncSession()` from `runner/session.ts` rather than repeating the launch and plug-in readiness sequence.
Future workflows should use `startObsidianLiveSyncSession()` from `runner/session.ts` rather than repeating the launch and plug-in readiness sequence. Add generic Obsidian bootstrap improvements to Fancy Kit; keep LiveSync behaviour and scenario helpers here.
Each test vault uses an isolated Obsidian profile. The runner creates temporary directories for `HOME`, `XDG_CONFIG_HOME`, `XDG_CACHE_HOME`, `XDG_DATA_HOME`, and Electron `--user-data-dir`, writes the vault registry into those directories, pre-seeds the temporary Chromium local storage so community plug-ins are trusted for that generated vault ID, and passes the same environment to `obsidian-cli`. This is intended to keep real Obsidian E2E runs separate from a developer's daily Obsidian profile and vault registry.
+6 -103
View File
@@ -1,103 +1,6 @@
import { spawn } from "node:child_process";
export type ObsidianCliResult = {
code: number | null;
signal: NodeJS.Signals | null;
stdout: string;
stderr: string;
};
function parseEvalJson(stdout: string): unknown {
const marker = "=> ";
const markerIndex = stdout.indexOf(marker);
const text = markerIndex >= 0 ? stdout.slice(markerIndex + marker.length) : stdout;
return JSON.parse(text.trim());
}
export async function runObsidianCli(
cliBinary: string,
args: string[],
env: NodeJS.ProcessEnv = process.env,
timeoutMs = Number(process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ?? 10000)
): Promise<ObsidianCliResult> {
return await new Promise((resolve, reject) => {
const child = spawn(cliBinary, args, {
stdio: ["ignore", "pipe", "pipe"],
env,
});
let stdout = "";
let stderr = "";
const timeout = setTimeout(() => {
child.kill("SIGKILL");
reject(new Error(`Obsidian CLI timed out: ${cliBinary} ${args.join(" ")}`));
}, timeoutMs);
child.stdout?.on("data", (chunk: Buffer) => {
stdout += chunk.toString();
});
child.stderr?.on("data", (chunk: Buffer) => {
stderr += chunk.toString();
});
child.on("error", (error) => {
clearTimeout(timeout);
reject(error);
});
child.on("exit", (code, signal) => {
clearTimeout(timeout);
resolve({ code, signal, stdout, stderr });
});
});
}
export async function openVaultWithObsidianCli(
cliBinary: string,
vaultPath: string,
env: NodeJS.ProcessEnv = process.env
): Promise<void> {
const result = await runObsidianCli(cliBinary, [`obsidian://open?path=${encodeURIComponent(vaultPath)}`], env);
if (result.code !== 0) {
throw new Error(
[
`Failed to open Obsidian vault through CLI. code=${result.code}, signal=${result.signal}`,
result.stdout ? `stdout:\n${result.stdout}` : undefined,
result.stderr ? `stderr:\n${result.stderr}` : undefined,
]
.filter(Boolean)
.join("\n")
);
}
}
export async function evalObsidianJson<T>(
cliBinary: string,
code: string,
env: NodeJS.ProcessEnv = process.env,
timeoutMs?: number
): Promise<T> {
const result = await runObsidianCli(cliBinary, ["eval", `code=${code}`], env, timeoutMs);
if (result.code !== 0) {
throw new Error(
[
`Failed to evaluate Obsidian JavaScript through CLI. code=${result.code}, signal=${result.signal}`,
result.stdout ? `stdout:\n${result.stdout}` : undefined,
result.stderr ? `stderr:\n${result.stderr}` : undefined,
]
.filter(Boolean)
.join("\n")
);
}
try {
return parseEvalJson(result.stdout) as T;
} catch (error) {
throw new Error(
[
`Failed to parse Obsidian CLI eval JSON. code=${result.code}, signal=${result.signal}`,
error instanceof Error ? `parse error: ${error.message}` : undefined,
result.stdout ? `stdout:\n${result.stdout}` : undefined,
result.stderr ? `stderr:\n${result.stderr}` : undefined,
]
.filter(Boolean)
.join("\n")
);
}
}
export {
evalObsidianJson,
openVaultWithObsidianCli,
runObsidianCli,
type ObsidianCliResult,
} from "@vrtmrz/obsidian-test-session";
+7 -149
View File
@@ -1,149 +1,7 @@
import { accessSync, constants, existsSync } from "node:fs";
import { resolve } from "node:path";
import { platform } from "node:process";
export type ObsidianDiscoveryResult = {
binary?: string;
source?: string;
checked: string[];
};
const defaultCandidatesByPlatform: Record<NodeJS.Platform, string[]> = {
aix: [],
android: [],
darwin: [
"/Applications/Obsidian.app/Contents/MacOS/Obsidian",
"/Applications/Obsidian.app/Contents/MacOS/obsidian",
],
freebsd: [],
haiku: [],
linux: [
"_testdata/obsidian/squashfs-root/obsidian",
"_testdata/obsidian/squashfs-root/AppRun",
"_testdata/obsidian/Obsidian-1.12.7-arm64.AppImage",
"_testdata/obsidian/Obsidian-1.12.7-x86_64.AppImage",
"/usr/bin/obsidian",
"/usr/local/bin/obsidian",
"/snap/bin/obsidian",
"/opt/Obsidian/obsidian",
"/opt/obsidian/obsidian",
"/app/bin/obsidian",
],
openbsd: [],
sunos: [],
win32: ["C:\\Program Files\\Obsidian\\Obsidian.exe", "C:\\Program Files (x86)\\Obsidian\\Obsidian.exe"],
cygwin: [],
netbsd: [],
};
const defaultCliCandidatesByPlatform: Record<NodeJS.Platform, string[]> = {
aix: [],
android: [],
darwin: [
"/Applications/Obsidian.app/Contents/MacOS/obsidian-cli",
"/Applications/Obsidian.app/Contents/Resources/obsidian-cli",
],
freebsd: [],
haiku: [],
linux: [
"_testdata/obsidian/squashfs-root/obsidian-cli",
"/usr/bin/obsidian-cli",
"/usr/local/bin/obsidian-cli",
"/snap/bin/obsidian-cli",
"/opt/Obsidian/obsidian-cli",
"/opt/obsidian/obsidian-cli",
],
openbsd: [],
sunos: [],
win32: ["C:\\Program Files\\Obsidian\\obsidian-cli.exe", "C:\\Program Files (x86)\\Obsidian\\obsidian-cli.exe"],
cygwin: [],
netbsd: [],
};
function isUsableFile(path: string): boolean {
const resolvedPath = resolve(path);
if (!existsSync(resolvedPath)) {
return false;
}
if (platform === "win32") {
return true;
}
try {
accessSync(resolvedPath, constants.X_OK);
return true;
} catch {
return false;
}
}
export function discoverObsidianBinary(env: NodeJS.ProcessEnv = process.env): ObsidianDiscoveryResult {
const checked: string[] = [];
const envBinary = env.OBSIDIAN_BINARY?.trim();
if (envBinary) {
checked.push(envBinary);
if (isUsableFile(envBinary)) {
return {
binary: resolve(envBinary),
source: "OBSIDIAN_BINARY",
checked,
};
}
}
const candidates = defaultCandidatesByPlatform[platform] ?? [];
for (const candidate of candidates) {
checked.push(candidate);
if (isUsableFile(candidate)) {
return {
binary: resolve(candidate),
source: "default-path",
checked,
};
}
}
return { checked };
}
export function requireObsidianBinary(env: NodeJS.ProcessEnv = process.env): string {
const result = discoverObsidianBinary(env);
if (!result.binary) {
throw new Error(
[
"Could not find an Obsidian executable.",
"Set OBSIDIAN_BINARY to the installed Obsidian executable path.",
`Checked paths: ${result.checked.length > 0 ? result.checked.join(", ") : "(none)"}`,
].join("\n")
);
}
return result.binary;
}
export function discoverObsidianCli(env: NodeJS.ProcessEnv = process.env): ObsidianDiscoveryResult {
const checked: string[] = [];
const envBinary = env.OBSIDIAN_CLI?.trim();
if (envBinary) {
checked.push(envBinary);
if (isUsableFile(envBinary)) {
return {
binary: resolve(envBinary),
source: "OBSIDIAN_CLI",
checked,
};
}
}
const candidates = defaultCliCandidatesByPlatform[platform] ?? [];
for (const candidate of candidates) {
checked.push(candidate);
if (isUsableFile(candidate)) {
return {
binary: resolve(candidate),
source: "default-path",
checked,
};
}
}
return { checked };
}
export {
discoverObsidianBinary,
discoverObsidianCli,
requireObsidianBinary,
requireObsidianCli,
type ObsidianDiscoveryResult,
} from "@vrtmrz/obsidian-test-session";
+17 -187
View File
@@ -1,196 +1,26 @@
import { execFile, spawn, type ChildProcess } from "node:child_process";
import { once } from "node:events";
import { existsSync } from "node:fs";
import { dirname } from "node:path";
import { platform } from "node:process";
import { promisify } from "node:util";
import {
cleanupStaleObsidianE2EProcesses as cleanupStaleProcesses,
launchObsidian as launchObsidianSession,
type LaunchObsidianOptions,
type ObsidianProcess,
type ObsidianProcessOutput,
} from "@vrtmrz/obsidian-test-session";
export type ObsidianProcess = {
process: ChildProcess;
output: () => { stdout: string; stderr: string };
stop: () => Promise<void>;
};
export type { LaunchObsidianOptions, ObsidianProcess, ObsidianProcessOutput };
export type LaunchObsidianOptions = {
binary: string;
vaultPath: string;
homePath?: string;
xdgConfigPath?: string;
xdgCachePath?: string;
xdgDataPath?: string;
userDataPath?: string;
startupGraceMs?: number;
};
const execFileAsync = promisify(execFile);
function splitArgs(args: string): string[] {
return args.split(" ").filter((arg) => arg.length > 0);
}
function launchArgs(options: LaunchObsidianOptions): string[] {
const explicitArgs = process.env.E2E_OBSIDIAN_ARGS;
if (explicitArgs) {
return splitArgs(explicitArgs);
}
return [
"--no-sandbox",
"--disable-gpu",
"--disable-software-rasterizer",
...(process.env.E2E_OBSIDIAN_USE_USER_DATA_DIR !== "false" && options.userDataPath
? [`--user-data-dir=${options.userDataPath}`]
: []),
...(process.env.E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT
? [`--remote-debugging-port=${process.env.E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT}`]
: []),
`obsidian://open?path=${encodeURIComponent(options.vaultPath)}`,
];
}
function shouldUseXvfb(): boolean {
if (process.env.E2E_OBSIDIAN_USE_XVFB === "false") {
return false;
}
if (process.env.DISPLAY || process.env.WAYLAND_DISPLAY) {
return false;
}
return platform === "linux" && existsSync("/usr/bin/xvfb-run");
}
async function listChildPids(pid: number): Promise<number[]> {
if (platform === "win32") {
return [];
}
const { stdout } = await execFileAsync("ps", ["-o", "pid=", "--ppid", String(pid)]).catch(() => ({
stdout: "",
}));
const directChildren = stdout
.split("\n")
.map((line) => Number(line.trim()))
.filter((childPid) => Number.isInteger(childPid) && childPid > 0);
const descendants = await Promise.all(directChildren.map((childPid) => listChildPids(childPid)));
return [...directChildren, ...descendants.flat()];
}
async function killPids(pids: number[], signal: NodeJS.Signals): Promise<void> {
for (const pid of pids) {
if (pid === process.pid) {
continue;
}
try {
process.kill(pid, signal);
} catch {
// The process may have exited between discovery and signalling.
}
}
}
async function waitForExit(exitPromise: Promise<unknown>, timeoutMs: number): Promise<"exited" | "timeout"> {
const stopTimer = new Promise<"timeout">((resolve) => {
setTimeout(() => resolve("timeout"), timeoutMs);
});
const stopResult = await Promise.race([exitPromise.then(() => "exited" as const), stopTimer]);
return stopResult;
}
const STALE_PROCESS_PATTERN = "obsidian-livesync-e2e-state";
export async function cleanupStaleObsidianE2EProcesses(): Promise<void> {
if (process.env.E2E_OBSIDIAN_CLEANUP_STALE_PROCESSES === "false" || platform === "win32") {
return;
}
const { stdout } = await execFileAsync("pgrep", ["-f", "obsidian-livesync-e2e-state"]).catch(() => ({
stdout: "",
}));
const pids = stdout
.split("\n")
.map((line) => Number(line.trim()))
.filter((pid) => Number.isInteger(pid) && pid > 0 && pid !== process.pid);
if (pids.length === 0) {
return;
}
await killPids(pids, "SIGTERM");
await new Promise((resolve) => setTimeout(resolve, 1000));
await killPids(pids, "SIGKILL");
await cleanupStaleProcesses(STALE_PROCESS_PATTERN);
}
export async function launchObsidian(options: LaunchObsidianOptions): Promise<ObsidianProcess> {
await cleanupStaleObsidianE2EProcesses();
const startupGraceMs = options.startupGraceMs ?? 1000;
const args = launchArgs(options);
const useXvfb = shouldUseXvfb();
const command = useXvfb ? "/usr/bin/xvfb-run" : options.binary;
const commandArgs = useXvfb ? ["-a", options.binary, ...args] : args;
const child = spawn(command, commandArgs, {
cwd: dirname(options.binary),
detached: true,
stdio: ["ignore", "pipe", "pipe"],
env: {
...process.env,
...(options.homePath ? { HOME: options.homePath } : {}),
...(options.xdgConfigPath ? { XDG_CONFIG_HOME: options.xdgConfigPath } : {}),
...(options.xdgCachePath ? { XDG_CACHE_HOME: options.xdgCachePath } : {}),
...(options.xdgDataPath ? { XDG_DATA_HOME: options.xdgDataPath } : {}),
OBSIDIAN_DISABLE_GPU: process.env.OBSIDIAN_DISABLE_GPU ?? "1",
},
const configuredPort =
options.env?.E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT ?? process.env.E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT;
return await launchObsidianSession({
...options,
remoteDebuggingPort:
options.remoteDebuggingPort ?? (configuredPort === undefined ? undefined : Number(configuredPort)),
staleProcessPattern: options.staleProcessPattern ?? STALE_PROCESS_PATTERN,
});
let stderr = "";
let stdout = "";
child.stderr?.on("data", (chunk: Buffer) => {
stderr += chunk.toString();
});
child.stdout?.on("data", (chunk: Buffer) => {
stdout += chunk.toString();
});
const exitPromise = once(child, "exit").then(([code, signal]) => ({ code, signal }));
const timer = new Promise<"timeout">((resolve) => {
setTimeout(() => resolve("timeout"), startupGraceMs);
});
const firstResult = await Promise.race([exitPromise, timer]);
if (firstResult !== "timeout") {
throw new Error(
[
`Obsidian exited before the smoke timeout. code=${firstResult.code}, signal=${firstResult.signal}`,
stdout ? `stdout:\n${stdout}` : undefined,
stderr ? `stderr:\n${stderr}` : undefined,
]
.filter(Boolean)
.join("\n")
);
}
return {
process: child,
output: () => ({ stdout, stderr }),
stop: async () => {
if (child.exitCode !== null || child.signalCode !== null) {
return;
}
const descendantPids = child.pid ? await listChildPids(child.pid) : [];
if (child.pid) {
try {
process.kill(-child.pid, "SIGTERM");
} catch {
child.kill("SIGTERM");
}
} else {
child.kill("SIGTERM");
}
await killPids(descendantPids.reverse(), "SIGTERM");
const stopResult = await waitForExit(exitPromise, 5000);
if (stopResult === "timeout") {
if (child.pid) {
try {
process.kill(-child.pid, "SIGKILL");
} catch {
child.kill("SIGKILL");
}
} else {
child.kill("SIGKILL");
}
await killPids(descendantPids, "SIGKILL");
await exitPromise;
}
},
};
}
+9 -35
View File
@@ -1,39 +1,13 @@
import { copyFile, mkdir, writeFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { join, resolve } from "node:path";
import {
installBuiltPlugin as installGenericBuiltPlugin,
type PluginInstallResult,
} from "@vrtmrz/obsidian-test-session";
export type PluginInstallResult = {
pluginDir: string;
copied: string[];
};
const pluginId = "obsidian-livesync";
export type { PluginInstallResult };
export async function installBuiltPlugin(vaultPath: string, rootDir = process.cwd()): Promise<PluginInstallResult> {
const pluginDir = join(vaultPath, ".obsidian", "plugins", pluginId);
const copied: string[] = [];
await mkdir(pluginDir, { recursive: true });
const requiredArtifacts = ["main.js", "manifest.json"];
for (const artifact of requiredArtifacts) {
const source = resolve(rootDir, artifact);
if (!existsSync(source)) {
throw new Error(`Required plug-in artifact is missing: ${source}`);
}
await copyFile(source, join(pluginDir, artifact));
copied.push(artifact);
}
const optionalArtifacts = ["styles.css"];
for (const artifact of optionalArtifacts) {
const source = resolve(rootDir, artifact);
if (!existsSync(source)) {
continue;
}
await copyFile(source, join(pluginDir, artifact));
copied.push(artifact);
}
await writeFile(join(vaultPath, ".obsidian", "community-plugins.json"), JSON.stringify([pluginId], null, 4));
return { pluginDir, copied };
return await installGenericBuiltPlugin(vaultPath, {
pluginId: "obsidian-livesync",
artifactRoot: rootDir,
});
}
+1 -41
View File
@@ -1,41 +1 @@
import { evalObsidianJson } from "./cli.ts";
export type PluginReadiness = {
status: "ready";
pluginId: string;
pluginVersion: string;
vaultName: string;
};
export async function waitForPluginReady(
cliBinary: string,
env: NodeJS.ProcessEnv,
timeoutMs = Number(process.env.E2E_OBSIDIAN_READY_TIMEOUT_MS ?? 20000)
): Promise<PluginReadiness> {
const deadline = Date.now() + timeoutMs;
let lastOutput = "";
while (Date.now() < deadline) {
try {
const readiness = await evalObsidianJson<PluginReadiness>(
cliBinary,
[
"(async()=>JSON.stringify({",
"status:!!app.plugins.plugins['obsidian-livesync']?'ready':'pending',",
"pluginId:'obsidian-livesync',",
"pluginVersion:app.plugins.manifests['obsidian-livesync']?.version,",
"vaultName:app.vault.getName()",
"}))()",
].join(""),
env
);
if (readiness.status === "ready") {
return readiness;
}
} catch (error) {
lastOutput = error instanceof Error ? error.message : String(error);
// Keep polling until Obsidian exposes the vault-side CLI and plug-in state.
}
await new Promise((resolve) => setTimeout(resolve, 500));
}
throw new Error(`Timed out waiting for Self-hosted LiveSync readiness through Obsidian CLI.\n${lastOutput}`);
}
export { waitForPluginReady, type PluginReadiness } from "@vrtmrz/obsidian-test-session";
+7 -102
View File
@@ -1,16 +1,7 @@
import { evalObsidianJson, openVaultWithObsidianCli, runObsidianCli } from "./cli.ts";
import { launchObsidian, type ObsidianProcess } from "./launch.ts";
import { installBuiltPlugin, type PluginInstallResult } from "./pluginInstaller.ts";
import { waitForPluginReady, type PluginReadiness } from "./readiness.ts";
import { startObsidianPluginSession, type ObsidianPluginSession } from "@vrtmrz/obsidian-test-session";
import type { TemporaryVault } from "./vault.ts";
import { obsidianRemoteDebuggingPort, preseedTrustedVaultState, trustVaultIfPrompted } from "./ui.ts";
export type ObsidianLiveSyncSession = {
app: ObsidianProcess;
cliEnv: NodeJS.ProcessEnv;
install: PluginInstallResult;
readiness: PluginReadiness;
};
export type ObsidianLiveSyncSession = ObsidianPluginSession;
export type StartObsidianLiveSyncSessionOptions = {
binary: string;
@@ -19,101 +10,15 @@ export type StartObsidianLiveSyncSessionOptions = {
startupGraceMs?: number;
};
async function waitForPluginCatalogue(cliBinary: string, env: NodeJS.ProcessEnv): Promise<void> {
const deadline = Date.now() + Number(process.env.E2E_OBSIDIAN_CLI_READY_TIMEOUT_MS ?? 60000);
let lastOutput = "";
while (Date.now() < deadline) {
try {
const result = await evalObsidianJson<{ hasLiveSync: boolean }>(
cliBinary,
["JSON.stringify({", "hasLiveSync:!!app.plugins?.manifests?.['obsidian-livesync']", "})"].join(""),
env
);
lastOutput = JSON.stringify(result);
if (result.hasLiveSync) {
return;
}
} catch (error) {
lastOutput = error instanceof Error ? error.message : String(error);
}
await new Promise((resolve) => setTimeout(resolve, 500));
}
throw new Error(`Timed out waiting for Obsidian plug-in catalogue through CLI.\n${lastOutput}`);
}
async function enableCommunityPlugins(cliBinary: string, env: NodeJS.ProcessEnv): Promise<void> {
const result = await runObsidianCli(cliBinary, ["eval", "code=(async()=>app.plugins.setEnable(true))()"], env);
if (result.code !== 0 || result.stdout.includes("Error:")) {
throw new Error(
[
`Failed to enable Obsidian community plug-ins through CLI. code=${result.code}, signal=${result.signal}`,
result.stdout ? `stdout:\n${result.stdout}` : undefined,
result.stderr ? `stderr:\n${result.stderr}` : undefined,
]
.filter(Boolean)
.join("\n")
);
}
}
async function reloadLiveSyncPlugin(cliBinary: string, env: NodeJS.ProcessEnv): Promise<void> {
const reload = await runObsidianCli(cliBinary, ["plugin:reload", "id=obsidian-livesync"], env);
if (reload.code !== 0 || !reload.stdout.includes("Reloaded: obsidian-livesync")) {
throw new Error(
[
`Failed to reload Self-hosted LiveSync through Obsidian CLI. code=${reload.code}, signal=${reload.signal}`,
reload.stdout ? `stdout:\n${reload.stdout}` : undefined,
reload.stderr ? `stderr:\n${reload.stderr}` : undefined,
]
.filter(Boolean)
.join("\n")
);
}
}
export async function startObsidianLiveSyncSession(
options: StartObsidianLiveSyncSessionOptions
): Promise<ObsidianLiveSyncSession> {
const install = await installBuiltPlugin(options.vault.path);
const remoteDebuggingPort = obsidianRemoteDebuggingPort();
const app = await launchObsidian({
return await startObsidianPluginSession({
binary: options.binary,
vaultPath: options.vault.path,
homePath: options.vault.homePath,
xdgConfigPath: options.vault.xdgConfigPath,
xdgCachePath: options.vault.xdgCachePath,
xdgDataPath: options.vault.xdgDataPath,
userDataPath: options.vault.userDataPath,
cliBinary: options.cliBinary,
vault: options.vault,
pluginId: "obsidian-livesync",
artifactRoot: process.cwd(),
startupGraceMs: options.startupGraceMs,
});
const cliEnv = {
...process.env,
HOME: options.vault.homePath,
XDG_CONFIG_HOME: options.vault.xdgConfigPath,
XDG_CACHE_HOME: options.vault.xdgCachePath,
XDG_DATA_HOME: options.vault.xdgDataPath,
};
try {
await preseedTrustedVaultState(remoteDebuggingPort, options.vault.id);
await openVaultWithObsidianCli(options.cliBinary, options.vault.path, cliEnv);
await trustVaultIfPrompted(remoteDebuggingPort);
await waitForPluginCatalogue(options.cliBinary, cliEnv);
await enableCommunityPlugins(options.cliBinary, cliEnv);
await reloadLiveSyncPlugin(options.cliBinary, cliEnv);
const readiness = await waitForPluginReady(options.cliBinary, cliEnv);
return { app, cliEnv, install, readiness };
} catch (error) {
const output = app.output();
await app.stop();
throw new Error(
[
error instanceof Error ? error.message : String(error),
output.stdout ? `Obsidian stdout:\n${output.stdout}` : undefined,
output.stderr ? `Obsidian stderr:\n${output.stderr}` : undefined,
]
.filter(Boolean)
.join("\n")
);
}
}
+7 -70
View File
@@ -1,74 +1,11 @@
import { chromium, type Page } from "playwright";
import { withObsidianPage } from "@vrtmrz/obsidian-test-session";
export function obsidianRemoteDebuggingPort(): number {
const port = Number(process.env.E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT ?? 9222);
process.env.E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT = String(port);
return port;
}
async function waitForCdp(port: number): Promise<void> {
const deadline = Date.now() + Number(process.env.E2E_OBSIDIAN_CDP_TIMEOUT_MS ?? 30000);
while (Date.now() < deadline) {
try {
const response = await fetch(`http://127.0.0.1:${port}/json/version`);
if (response.ok) {
return;
}
} catch {
// Keep polling until Obsidian exposes the debugging endpoint.
}
await new Promise((resolve) => setTimeout(resolve, 500));
}
throw new Error(`Timed out waiting for Obsidian DevTools endpoint on port ${port}`);
}
export async function withObsidianPage<T>(port: number, operation: (page: Page) => Promise<T>): Promise<T> {
await waitForCdp(port);
const browser = await chromium.connectOverCDP(`http://127.0.0.1:${port}`);
try {
const context = browser.contexts()[0];
const page = context.pages()[0] ?? (await context.waitForEvent("page", { timeout: 10000 }));
return await operation(page);
} finally {
await browser.close();
}
}
export async function preseedTrustedVaultState(port: number, vaultId: string): Promise<void> {
await withObsidianPage(port, async (page) => {
await page.evaluate((id) => {
localStorage.setItem(`enable-plugin-${id}`, "true");
}, vaultId);
await page.reload({ waitUntil: "domcontentloaded", timeout: 10000 }).catch(() => undefined);
await page.waitForTimeout(1000);
});
}
export async function trustVaultIfPrompted(port: number): Promise<void> {
await withObsidianPage(port, async (page) => {
const deadline = Date.now() + Number(process.env.E2E_OBSIDIAN_TRUST_PROMPT_TIMEOUT_MS ?? 30000);
while (Date.now() < deadline) {
const yesButton = page.getByRole("button", { name: "Yes" });
if (await yesButton.isVisible({ timeout: 1000 }).catch(() => false)) {
await yesButton.click();
await page.waitForTimeout(500);
continue;
}
const trustButton = page.getByText("Trust author and enable plugins");
if (await trustButton.isVisible({ timeout: 1000 }).catch(() => false)) {
await trustButton.click();
await page.waitForTimeout(500);
continue;
}
const workspace = page.locator(".workspace");
if (await workspace.isVisible({ timeout: 1000 }).catch(() => false)) {
return;
}
}
});
}
export {
obsidianRemoteDebuggingPort,
preseedTrustedVaultState,
trustVaultIfPrompted,
withObsidianPage,
} from "@vrtmrz/obsidian-test-session";
export async function clickJsonResolveOption(port: number, mode: "AB" | "BA"): Promise<void> {
await withObsidianPage(port, async (page) => {
+10 -90
View File
@@ -1,94 +1,14 @@
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import {
createTemporaryVault as createGenericTemporaryVault,
type TemporaryVault,
} from "@vrtmrz/obsidian-test-session";
export type TemporaryVault = {
path: string;
name: string;
id: string;
homePath: string;
xdgConfigPath: string;
xdgCachePath: string;
xdgDataPath: string;
userDataPath: string;
dispose: () => Promise<void>;
};
export type { TemporaryVault };
export async function createTemporaryVault(prefix = "obsidian-livesync-e2e-"): Promise<TemporaryVault> {
const vaultPath = await mkdtemp(join(tmpdir(), prefix));
const statePath = await mkdtemp(join(tmpdir(), `${prefix}state-`));
const name = vaultPath.split(/[\\/]/).pop() ?? "obsidian-livesync-e2e";
await mkdir(join(vaultPath, ".obsidian"), { recursive: true });
const homePath = join(statePath, "home");
const xdgConfigPath = join(statePath, "xdg-config");
const xdgCachePath = join(statePath, "xdg-cache");
const xdgDataPath = join(statePath, "xdg-data");
const userDataPath = join(statePath, "user-data");
const id = `livesync-e2e-${Date.now()}`;
await mkdir(homePath, { recursive: true });
await mkdir(xdgConfigPath, { recursive: true });
await mkdir(xdgCachePath, { recursive: true });
await mkdir(xdgDataPath, { recursive: true });
await mkdir(userDataPath, { recursive: true });
await writeFile(
join(vaultPath, ".obsidian", "app.json"),
JSON.stringify({ legacyEditor: false, safeMode: false }, null, 4)
);
await writeFile(
join(vaultPath, ".obsidian", "community-plugins.json"),
JSON.stringify(["obsidian-livesync"], null, 4)
);
await writeObsidianVaultRegistry(id, vaultPath, name, homePath, xdgConfigPath, userDataPath);
return {
path: vaultPath,
name,
id,
homePath,
xdgConfigPath,
xdgCachePath,
xdgDataPath,
userDataPath,
dispose: async () => {
if (process.env.E2E_OBSIDIAN_KEEP_VAULT === "true") {
console.log(`Keeping temporary vault: ${vaultPath}`);
console.log(`Keeping temporary Obsidian state: ${statePath}`);
return;
}
await Promise.all([
rm(vaultPath, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }),
rm(statePath, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }),
]);
},
};
}
async function writeObsidianVaultRegistry(
vaultId: string,
vaultPath: string,
vaultName: string,
homePath: string,
xdgConfigPath: string,
userDataPath: string
): Promise<void> {
const vaultRecord = {
path: vaultPath,
ts: Date.now(),
open: true,
name: vaultName,
};
const registry = {
cli: true,
vaults: {
[vaultId]: vaultRecord,
},
};
const registryText = JSON.stringify(registry, null, 4);
for (const configRoot of [join(homePath, ".config"), xdgConfigPath]) {
const obsidianConfigDir = join(configRoot, "obsidian");
await mkdir(obsidianConfigDir, { recursive: true });
await writeFile(join(obsidianConfigDir, "obsidian.json"), registryText);
}
await writeFile(join(userDataPath, "obsidian.json"), registryText);
await writeFile(join(userDataPath, `${vaultId}.json`), JSON.stringify(vaultRecord, null, 4));
return await createGenericTemporaryVault({
prefix,
pluginIds: ["obsidian-livesync"],
idPrefix: "livesync-e2e",
});
}
+16 -36
View File
@@ -3,6 +3,22 @@ Since 19th July, 2025 (beta1 in 0.25.0-beta1, 13th July, 2025)
The head note of 0.25 is now in [updates_old.md](https://github.com/vrtmrz/obsidian-livesync/blob/main/updates_old.md). Because 0.25 got a lot of updates, thankfully, compatibility is kept and we do not need breaking changes! In other words, when get enough stabled. The next version will be v1.0.0. Even though it my hope.
## 0.25.80
7th July, 2026
### Fixed
- Improved Markdown conflict auto-merge so that non-overlapping edits are merged while overlapping delete-and-edit cases remain visible for manual resolution (#993).
- Behaviour change:
- When one side deletes an unchanged line and the other side edits a different region, the deleted line is no longer reintroduced into the merged result.
- When one side deletes a line and the other side modifies that same line, the conflict is preserved instead of silently choosing one side.
- Fixed an issue where applying a newer database entry to storage could incorrectly preserve an older local file as a conflict (#994).
- Behaviour change:
- Local storage is preserved as a conflict when it may contain unsynchronised changes that are not represented in the revision history. A newer incoming text entry is applied without creating a conflict only when it clearly extends the existing local text.
- Fixed an issue where choosing Disable and then Overwrite in Hidden File Sync could silently skip hidden files, because the overwrite setup ran while hidden file synchronisation was still disabled (#989, PR #992).
- Hidden File Sync is now re-enabled before the Fetch, Overwrite, or Merge initialisation runs, instead of after it completes. If that initialisation fails, the setting may remain enabled.
## 0.25.79
29th June, 2026
@@ -96,41 +112,5 @@ Also, this update is a very large one, even if we had a lot of time, and we had
- Some dependencies have been updated.
- Now we check the compatibility with iOS 15 in the CI tests to ensure the plugin continues to work on older iOS versions even after we upgrade some dependencies.
## 0.25.74
8th June, 2026
### Fixed
- Fixed an issue where disabling hidden file synchronisation did not take effect, allowing non-target hidden files to continue to be processed and synchronised by replication or boot-sequence scan (#941).
- Prevented the automatic merging of conflicted revisions when one of the revisions has been deleted, which was causing deleted files to reappear (#911).
- The startup sequence now saves the state more effectively (Thank you so much for @bmcyver)!
## Only CLI
8th June, 2026
I should also consider the version numbering for the CLI...
### Improved
- Added new remote database management commands: `remote-status`, `unlock-remote`, `lock-remote`, and `mark-resolved`.
- --vault option is now available for daemon and mirror commands! (Thank you so much for @starskyzheng)!
- Decoupled the database directory path from the actual vault directory path using the `--vault` (or `-V`) option.
### Fixed (preventive)
- Validated that the specified vault path exists and is indeed a directory before starting the CLI.
- Integrated path resolution and validations for one-off commands (such as `'push'`, `'pull'`, `'cat'`, `'rm'`, `'info'`, and `'resolve'`) against the decoupled vault path instead of the database path.
## 0.25.73
4th June, 2026
### Fixed
- Adjust CouchDB's database name checking to its specification (#926).
- `Reset Syncronisation on This Device` for minio and P2P is now working properly.
Full notes are in
[updates_old.md](https://github.com/vrtmrz/obsidian-livesync/blob/main/updates_old.md).
+58
View File
@@ -4,6 +4,64 @@ Since 19th July, 2025 (beta1 in 0.25.0-beta1, 13th July, 2025)
The head note of 0.25 is now in [updates_old.md](https://github.com/vrtmrz/obsidian-livesync/blob/main/updates_old.md). Because 0.25 got a lot of updates, thankfully, compatibility is kept and we do not need breaking changes! In other words, when get enough stabled. The next version will be v1.0.0. Even though it my hope.
## 0.25.80
7th July, 2026
### Fixed
- Improved Markdown conflict auto-merge so that non-overlapping edits are merged while overlapping delete-and-edit cases remain visible for manual resolution (#993).
- Behaviour change:
- When one side deletes an unchanged line and the other side edits a different region, the deleted line is no longer reintroduced into the merged result.
- When one side deletes a line and the other side modifies that same line, the conflict is preserved instead of silently choosing one side.
- Fixed an issue where applying a newer database entry to storage could incorrectly preserve an older local file as a conflict (#994).
- Behaviour change:
- Local storage is preserved as a conflict when it may contain unsynchronised changes that are not represented in the revision history. A newer incoming text entry is applied without creating a conflict only when it clearly extends the existing local text.
- Fixed an issue where choosing Disable and then Overwrite in Hidden File Sync could silently skip hidden files, because the overwrite setup ran while hidden file synchronisation was still disabled (#989, PR #992).
- Hidden File Sync is now re-enabled before the Fetch, Overwrite, or Merge initialisation runs, instead of after it completes. If that initialisation fails, the setting may remain enabled.
## 0.25.79
29th June, 2026
### Fixed
- Fast Fetch now retries transient stream interruptions and resumes from the latest persisted checkpoint, instead of starting over after ordinary network or platform interruptions (#977, PR #978; commonlib PR #59). Thank you so much for @apple-ouyang for the fix!
- Simple Fetch now remembers the selected setup choices while an interrupted Fetch All operation is still pending, so users are not asked the same questions again on retry (#977, PR #978). Thank you so much for @apple-ouyang for the fix!
- No longer hidden storage events, such as `.git` paths, reach the normal target-file filter when internal file synchronisation is disabled. This avoids noisy non-target logs before those files are skipped (commonlib PR #60). Thank you so much for @apple-ouyang for the fix!
- Fixed an issue where a file deleted from storage could be resurrected by the offline scanner because the database tombstone was not written when the storage file was already gone (commonlib PR #56). Thank you so much for @cosmic-fire-eng for the fix!
### Improved
- Local database maintenance commands now ask before applying the required chunk settings, and can apply those prerequisites before continuing (#980, PR #981). Thank you so much for @apple-ouyang for the improvement!
- Improved CouchDB replication event handling by using the new `StreamInbox` helper from `octagonal-wheels` (commonlib PR #62).
### Documentation
- Added `nginx` to the setup documentation table of contents (PR #976). Thank you so much for @kiraventom for the improvement!
### Miscellaneous
- Updated `octagonal-wheels` to `0.1.47` across the plug-in and workspace packages to use the newly published helper modules.
## 0.25.78
23rd June, 2026
### Fixed
- No longer fast synchronisation (a.k.a. Fast Fetch) causes a rewind and re-fetch of the entire database when some errors occur during the process (#972, PR #973). Thank you so much for @apple-ouyang for the fix!
### Improved
- Overhauled the Object Storage (e.g., MinIO and S3) replication engine ('Journal Replicator 2nd Edition').
- It now leverages the standard Web Streams API for a resilient, backpressure-aware architecture, reducing memory footprints/temporary storage usage on large vaults.
- Decoupled the physical storage logic to make it easier to add new storage backends in the future.
- Stricter compliance with CouchDB's replication protocol (proper `_revisions` transfers with `new_edits: false`) when using Object Storage.
### Testing
- Added comprehensive unit tests for the new `JournalSyncCore` engine, covering streams, backpressure, and `new_edits: false` validation.
- Improved integration test workflows in the CI pipeline to run MinIO tests automatically using standard environment variables.
## 0.25.77
19th June, 2026