Fixed the false conflict detection on my own node.

This commit is contained in:
Koichi Nakamura 2026-07-19 07:44:11 +09:00
parent 94e3cbc521
commit 1a5e40d303
6 changed files with 221 additions and 59 deletions

View file

@ -2,6 +2,38 @@
SharedVault is a serverless, conflict-free collaboration layer for Obsidian. It stores Yjs-compatible operation files inside the vault, keeps local CRDT cache state under .obsidian/cache/{vault-id}/{node-id}/, and applies remote changes later without a dedicated server.
```mermaid
flowchart LR
subgraph Node["Each device node"]
Editor["Markdown files in vault"]
Cache["Local CRDT cache\n.obsidian/cache/{vault-id}/{node-id}/docs"]
State["Processed state\n.obsidian/cache/{vault-id}/{node-id}/state.json"]
LocalData["Local plugin data\n.obsidian/cache/{vault-id}/local-plugin-data.json"]
end
subgraph Shared["Shared vault storage"]
Ops["Operation cache\n.obsidian/shared-vault/operation-cache/"]
Registry["Node registry\n.obsidian/shared-vault/node-registry/"]
Snapshots["Snapshots\n.obsidian/shared-vault/snapshots/"]
end
Editor -->|local diff| Cache
Cache -->|Yjs update files| Ops
Ops -->|poll and apply| Cache
Cache -->|write merged markdown| Editor
Cache --> State
LocalData -->|persist nodeId| Cache
Editor -->|first local edit / heartbeat| Registry
Cache -->|snapshot export| Snapshots
```
> __An image of editing this README.md on my own node and viewing it from another node__
![An image of editing this README.md on my own node and viewing it from another nod](./Screenshot.gif)
> [!NOTE]
> The mouse cursor on the other node is positioned at the bottomright, showing that it is reflecting the edits made on my own node.
## Current implementation
- Shared operation log in .obsidian/shared-vault/operation-cache/

BIN
Screenshot.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

View file

@ -1,7 +1,7 @@
{
"id": "shared-vault",
"name": "Shared Vault",
"version": "0.1.3",
"version": "0.2.0",
"minAppVersion": "1.5.0",
"description": "Serverless, conflict-free collaboration layer for shared vaults.",
"author": "fangface",

View file

@ -1,32 +1,32 @@
{
"name": "obsidian-shared-vault",
"version": "0.1.3",
"description": "Serverless conflict-free collaboration layer for Obsidian shared vaults.",
"private": true,
"type": "module",
"scripts": {
"build": "node esbuild.config.mjs production",
"dev": "node esbuild.config.mjs",
"check": "tsc --noEmit",
"lint": "eslint src/main.ts --max-warnings=0",
"lint:fix": "eslint src/main.ts --fix --max-warnings=0",
"generate:release-notes": "node scripts/generate-release-notes.mjs",
"version:major": "node scripts/bump-major-version.mjs",
"version:minor": "node scripts/bump-minor-version.mjs",
"version:patch": "node scripts/bump-patch-version.mjs"
},
"devDependencies": {
"@typescript-eslint/parser": "^8.57.2",
"@types/diff-match-patch": "latest",
"@types/node": "latest",
"esbuild": "latest",
"eslint": "^9.39.4",
"eslint-plugin-obsidianmd": "^0.4.1",
"obsidian": "latest",
"typescript": "latest"
},
"dependencies": {
"diff-match-patch": "latest",
"yjs": "latest"
}
}
{
"name": "obsidian-shared-vault",
"version": "0.2.0",
"description": "Serverless conflict-free collaboration layer for Obsidian shared vaults.",
"private": true,
"type": "module",
"scripts": {
"build": "node esbuild.config.mjs production",
"dev": "node esbuild.config.mjs",
"check": "tsc --noEmit",
"lint": "eslint src/main.ts --max-warnings=0",
"lint:fix": "eslint src/main.ts --fix --max-warnings=0",
"generate:release-notes": "node scripts/generate-release-notes.mjs",
"version:major": "node scripts/bump-major-version.mjs",
"version:minor": "node scripts/bump-minor-version.mjs",
"version:patch": "node scripts/bump-patch-version.mjs"
},
"devDependencies": {
"@typescript-eslint/parser": "^8.57.2",
"@types/diff-match-patch": "latest",
"@types/node": "latest",
"esbuild": "latest",
"eslint": "^9.39.4",
"eslint-plugin-obsidianmd": "^0.4.1",
"obsidian": "latest",
"typescript": "latest"
},
"dependencies": {
"diff-match-patch": "latest",
"yjs": "latest"
}
}

View file

@ -10,8 +10,19 @@ import {
import { SharedVaultEngine } from "./shared-vault-engine";
import { SharedVaultNodeRegistryModal } from "./shared-vault-modals";
import { SharedVaultSettingTab } from "./shared-vault-settings-tab";
import type { NodeListEntry, NodeRegistryEntry, OperationFile, SharedVaultData, SharedVaultSettings } from "./shared-vault-types";
import { hashText, safeMkdir } from "./shared-vault-utils";
import type { CacheState, CachedDocument, NodeListEntry, NodeRegistryEntry, OperationFile, SharedVaultData, SharedVaultSettings } from "./shared-vault-types";
import { base64ToText, hashText, safeMkdir } from "./shared-vault-utils";
interface LegacyCacheCandidate {
root: string;
statePath: string;
docRoot: string;
hasState: boolean;
hasDocs: boolean;
processedCount: number;
docCount: number;
alignedDocCount: number;
}
export default class SharedVaultPlugin extends Plugin {
settings: SharedVaultSettings = DEFAULT_SETTINGS;
@ -239,38 +250,156 @@ export default class SharedVaultPlugin extends Plugin {
const modernStatePath = normalizePath(`${nodeCacheRoot}/state.json`);
const modernDocRoot = normalizePath(`${nodeCacheRoot}/docs`);
if (await adapter.exists(modernStatePath) || await adapter.exists(modernDocRoot)) {
const listed = await adapter.list(nodeCacheRoot);
const legacyCandidates = await this.readLegacyCacheCandidates(listed.folders.sort());
if (legacyCandidates.length === 0) {
return;
}
const listed = await adapter.list(nodeCacheRoot);
const legacyRoots = listed.folders.sort();
const hasModernState = await adapter.exists(modernStatePath);
const hasModernDocs = await adapter.exists(modernDocRoot);
const modernCandidate = await this.inspectCacheCandidate(nodeCacheRoot);
const selectedLegacy = this.selectPreferredCacheCandidate(legacyCandidates);
for (const legacyRoot of legacyRoots) {
const legacyStatePath = normalizePath(`${legacyRoot}/state.json`);
const legacyDocRoot = normalizePath(`${legacyRoot}/docs`);
const hasLegacyState = await adapter.exists(legacyStatePath);
const hasLegacyDocs = await adapter.exists(legacyDocRoot);
const shouldRecoverModernCache = (hasModernState || hasModernDocs)
&& modernCandidate.alignedDocCount === 0
&& selectedLegacy.alignedDocCount > 0;
if (!hasLegacyState && !hasLegacyDocs) {
if (!shouldRecoverModernCache && (hasModernState || hasModernDocs)) {
return;
}
if (selectedLegacy.hasDocs) {
if (hasModernDocs) {
await adapter.rmdir(modernDocRoot, true);
}
await adapter.rename(selectedLegacy.docRoot, modernDocRoot);
}
const mergedState = await this.mergeProcessedState([
...(hasModernState ? [modernCandidate] : []),
...legacyCandidates
]);
if (mergedState.processedOpIds.length > 0) {
await adapter.write(modernStatePath, JSON.stringify(mergedState, null, 2));
}
if (selectedLegacy.hasState && await adapter.exists(selectedLegacy.statePath)) {
await adapter.remove(selectedLegacy.statePath);
}
for (const candidate of legacyCandidates) {
const rest = await adapter.list(candidate.root);
if (rest.files.length === 0 && rest.folders.length === 0) {
await adapter.rmdir(candidate.root, false);
}
}
}
private async readLegacyCacheCandidates(roots: string[]): Promise<LegacyCacheCandidate[]> {
const candidates: LegacyCacheCandidate[] = [];
for (const root of roots) {
const candidate = await this.inspectCacheCandidate(root);
if (!candidate.hasState && !candidate.hasDocs) {
continue;
}
if (hasLegacyState && !(await adapter.exists(modernStatePath))) {
await adapter.rename(legacyStatePath, modernStatePath);
}
if (hasLegacyDocs && !(await adapter.exists(modernDocRoot))) {
await adapter.rename(legacyDocRoot, modernDocRoot);
}
const rest = await adapter.list(legacyRoot);
if (rest.files.length === 0 && rest.folders.length === 0) {
await adapter.rmdir(legacyRoot, false);
}
return;
candidates.push(candidate);
}
return candidates;
}
private async inspectCacheCandidate(root: string): Promise<LegacyCacheCandidate> {
const adapter = this.app.vault.adapter;
const statePath = normalizePath(`${root}/state.json`);
const docRoot = normalizePath(`${root}/docs`);
const hasState = await adapter.exists(statePath);
const hasDocs = await adapter.exists(docRoot);
let processedCount = 0;
if (hasState) {
const raw = await adapter.read(statePath);
const state = JSON.parse(raw) as CacheState;
processedCount = state.processedOpIds.length;
}
let docCount = 0;
let alignedDocCount = 0;
if (hasDocs) {
const listed = await adapter.list(docRoot);
const docPaths = listed.files.filter((path) => path.endsWith(".json")).sort();
docCount = docPaths.length;
for (const path of docPaths) {
const raw = await adapter.read(path);
const document = JSON.parse(raw) as CachedDocument;
if (!document.vaultTextBase64) {
continue;
}
const file = this.app.vault.getAbstractFileByPath(document.path);
if (!(file instanceof TFile)) {
continue;
}
if (base64ToText(document.vaultTextBase64) === await this.app.vault.read(file)) {
alignedDocCount += 1;
}
}
}
return {
root,
statePath,
docRoot,
hasState,
hasDocs,
processedCount,
docCount,
alignedDocCount
};
}
private selectPreferredCacheCandidate(candidates: LegacyCacheCandidate[]): LegacyCacheCandidate {
return candidates.slice().sort((left, right) => {
if (right.alignedDocCount !== left.alignedDocCount) {
return right.alignedDocCount - left.alignedDocCount;
}
if (right.processedCount !== left.processedCount) {
return right.processedCount - left.processedCount;
}
if (right.docCount !== left.docCount) {
return right.docCount - left.docCount;
}
return left.root.localeCompare(right.root);
})[0];
}
private async mergeProcessedState(candidates: LegacyCacheCandidate[]): Promise<CacheState> {
const processedOpIds = new Set<string>();
for (const candidate of candidates) {
if (!candidate.hasState || !(await this.app.vault.adapter.exists(candidate.statePath))) {
continue;
}
const raw = await this.app.vault.adapter.read(candidate.statePath);
const state = JSON.parse(raw) as CacheState;
for (const operationId of state.processedOpIds) {
processedOpIds.add(operationId);
}
}
return {
processedOpIds: Array.from(processedOpIds).sort().slice(-5000)
};
}
private async prepareNodeParticipation(): Promise<void> {

View file

@ -2,5 +2,6 @@
"0.1.0": "1.5.0",
"0.1.1": "1.5.0",
"0.1.2": "1.5.0",
"0.1.3": "1.5.0"
"0.1.3": "1.5.0",
"0.2.0": "1.5.0"
}