Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/sync/diskMirror.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
type FrontmatterValidationResult,
} from "./frontmatterGuard";
import { isLocalOrigin } from "./origins";
import { isMarkdownConflictArtifactPath } from "./markdownConflictArtifact";
import { contentBaselineHash } from "./diskIndex";
import { PreservedUnresolvedRegistry, type PreservedUnresolvedEntry, type PreservedUnresolvedReason } from "./preservedUnresolved";
export { isLocalOrigin };
Expand Down Expand Up @@ -502,6 +503,11 @@ export class DiskMirror {

async flushWrite(path: string, force = false): Promise<void> {
path = normalizePath(path);
// Older clients admitted safety copies into the shared document. The
// upload filter alone cannot protect a user deleting one locally: its
// CRDT entry survives, so reconciliation or a remote edit can recreate
// it. Enforce locality on every writeback, including forced writes.
if (isMarkdownConflictArtifactPath(path)) return;
return this.runPathWriteLocked(path, () => this.flushWriteUnlocked(path, force));
}

Expand Down
2 changes: 1 addition & 1 deletion src/sync/markdownConflictArtifact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
* Stamp is ISO-8601 with `:` replaced by `-`.
*/
const MARKDOWN_CONFLICT_ARTIFACT_RE =
/ \(YAOS conflict(?: - (?:crdt|disk|editor))? from .+ \d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z\)(?: \d+)?\.md$/u;
/ \(YAOS conflict(?: - (?:crdt|disk|editor))? from .+ \d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z\)(?: \d+)?\.[mM][dD]$/u;

export function isMarkdownConflictArtifactPath(path: string): boolean {
const normalized = path.replace(/\\/g, "/");
Expand Down
8 changes: 7 additions & 1 deletion src/sync/vaultSync.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as Y from "yjs";
import { isMarkdownConflictArtifactPath } from "./markdownConflictArtifact";
import YSyncProvider from "y-partyserver/provider";
import { IndexeddbPersistence } from "y-indexeddb";
import { normalizePath } from "obsidian";
Expand Down Expand Up @@ -1191,7 +1192,11 @@ export class VaultSync {
let skipped = 0;

this.ensurePathIndexes();
const crdtPaths = new Set<string>(this._pathIndex.keys());
// Keep legacy synced artifacts in the document for recovery, but never
// plan disk creates/overwrites for these now-local-only safety copies.
const crdtPaths = new Set<string>(
[...this._pathIndex.keys()].filter((path) => !isMarkdownConflictArtifactPath(path)),
);

// CRDT files not on disk → create on disk
// IMPORTANT: use diskPresentPaths (all known disk paths), not
Expand Down Expand Up @@ -1221,6 +1226,7 @@ export class VaultSync {

// Disk files not in CRDT
for (const path of diskPresentPaths) {
if (isMarkdownConflictArtifactPath(path)) continue;
const classification = classifyDiskPathForReconcile(
path,
crdtPaths.has(path),
Expand Down
71 changes: 71 additions & 0 deletions tests/client/legacy-conflict-writeback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { webcrypto } from "node:crypto";
import { TFile, type App } from "obsidian";
import * as Y from "yjs";
import { DiskMirror } from "../../src/sync/diskMirror";
import { VaultSync } from "../../src/sync/vaultSync";
import type { EditorBindingManager } from "../../src/sync/editorBinding";
import { isMarkdownSyncable } from "../../src/types";
import { partialOf } from "../mocks/productFixture";
import { suite } from "../harness";

const s = suite("legacy-conflict-writeback");
Object.defineProperty(globalThis, "window", { value: { crypto: webcrypto }, configurable: true });

const artifact = "note (YAOS conflict - crdt from Laptop 2026-08-27T11-05-00Z).md";
const paths = [artifact, artifact.replace(/\.md$/, ".MD"), "note.md"];

for (const path of paths) {
s.test(`writeback respects local-only policy: ${path}`, async () => {
const doc = new Y.Doc();
const text = doc.getText("body");
text.insert(0, "remote content");
const disk = new Map<string, string>();
const errors: unknown[] = [];
const app = partialOf<App>({
vault: {
getAbstractFileByPath: (p) => disk.has(p) ? Object.assign(new TFile(), { path: p }) : null,
read: async (file) => disk.get(file.path)!,
create: async (p, content) => { disk.set(p, content); return Object.assign(new TFile(), { path: p }); },
modify: async (file, content) => { disk.set(file.path, content); },
},
});
const mirror = new DiskMirror(app, partialOf<VaultSync>({ getTextForPath: () => text }),
partialOf<EditorBindingManager>({}), false, undefined, () => false);
mirror.setFlightEventHandler((event) => { if (event.kind === "disk.write.failed") errors.push(event); });
try {
const localOnly = path !== "note.md";
s.check(isMarkdownSyncable(path, [], ".obsidian") === !localOnly, "admission matches artifact policy");
await mirror.flushWrite(path);
s.check(disk.has(path) === !localOnly, "legacy CRDT artifact is not materialized; normal note is");
disk.delete(path);
await mirror.flushWrite(path, true);
s.check(disk.has(path) === !localOnly, "forced write cannot resurrect a deleted local artifact");
disk.set(path, "local safety copy");
await mirror.flushWrite(path, true);
s.check(disk.get(path) === (localOnly ? "local safety copy" : "remote content"), "existing artifact is not overwritten");
s.check(errors.length === 0, "no hidden write errors");
} finally { doc.destroy(); }
});
}

s.test("reconcile does not plan writes for previously synced conflict artifacts", () => {
const doc = new Y.Doc();
const sync: VaultSync = Object.assign(Object.create(VaultSync.prototype), {
_pathIndexesDirty: false,
_pathIndex: new Map(paths.map((p) => [p, p])),
_deletedPathIndex: new Set(),
_eventRing: [],
sys: doc.getMap("sys"),
debug: false,
getTextForPath: () => doc.getText("body"),
});
doc.getText("body").insert(0, "remote");
try {
const absent = sync.reconcileVault(new Map(), new Set(), "authoritative");
s.check(JSON.stringify(absent.createdOnDisk) === JSON.stringify(["note.md"]), "only normal missing notes are scheduled for creation");
const present = sync.reconcileVault(new Map(paths.map((p) => [p, "local"])), new Set(paths), "authoritative");
s.check(JSON.stringify(present.updatedOnDisk) === JSON.stringify(["note.md"]), "only normal existing notes are scheduled for overwrite");
} finally { doc.destroy(); }
});

await s.done();