diff --git a/src/sync/diskMirror.ts b/src/sync/diskMirror.ts index 684378a3..a5d2f695 100644 --- a/src/sync/diskMirror.ts +++ b/src/sync/diskMirror.ts @@ -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 }; @@ -502,6 +503,11 @@ export class DiskMirror { async flushWrite(path: string, force = false): Promise { 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)); } diff --git a/src/sync/markdownConflictArtifact.ts b/src/sync/markdownConflictArtifact.ts index 4a5f56cd..d620f57c 100644 --- a/src/sync/markdownConflictArtifact.ts +++ b/src/sync/markdownConflictArtifact.ts @@ -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, "/"); diff --git a/src/sync/vaultSync.ts b/src/sync/vaultSync.ts index cae78e91..7842e108 100644 --- a/src/sync/vaultSync.ts +++ b/src/sync/vaultSync.ts @@ -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"; @@ -1191,7 +1192,11 @@ export class VaultSync { let skipped = 0; this.ensurePathIndexes(); - const crdtPaths = new Set(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( + [...this._pathIndex.keys()].filter((path) => !isMarkdownConflictArtifactPath(path)), + ); // CRDT files not on disk → create on disk // IMPORTANT: use diskPresentPaths (all known disk paths), not @@ -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), diff --git a/tests/client/legacy-conflict-writeback.ts b/tests/client/legacy-conflict-writeback.ts new file mode 100644 index 00000000..1c0c9948 --- /dev/null +++ b/tests/client/legacy-conflict-writeback.ts @@ -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(); + const errors: unknown[] = []; + const app = partialOf({ + 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({ getTextForPath: () => text }), + partialOf({}), 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();