From 4a245debe7e29c5d34a081f41ac8a3664600d7c7 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Fri, 6 Nov 2026 15:28:46 +0000 Subject: [PATCH 1/3] fix: inspect and repair incorrect recorded file sizes --- docs/recovery.md | 1 + docs/settings.md | 2 +- docs/specs_conflict_resolution.md | 2 + .../messages/LiveSyncProvisionalMessages.ts | 1 + .../features/SettingDialogue/PaneHatch.ts | 27 +++ src/serviceFeatures/fileRepair.ts | 102 ++++++++-- src/serviceFeatures/fileRepair.unit.spec.ts | 160 +++++++++++++--- src/serviceFeatures/fileRepairPresentation.ts | 7 +- .../fileRepairPresentation.unit.spec.ts | 63 +++++++ test/e2e-obsidian/scripts/revision-repair.ts | 174 +++++++++++++++++- updates.md | 6 + 11 files changed, 488 insertions(+), 57 deletions(-) diff --git a/docs/recovery.md b/docs/recovery.md index 06b09edd..587238fa 100644 --- a/docs/recovery.md +++ b/docs/recovery.md @@ -98,6 +98,7 @@ The `Hatch` recovery controls are ordered by escalation. Running **Recreate chun - **Compare with Vault** opens a read-only comparison for readable text. - **Apply this revision to Vault** replaces the Vault file with that readable database revision. - **Mark this revision as the Vault version** records an exact byte-for-byte match without creating a child revision. + - **Correct recorded file size** is available when a readable, conflict-free database winner has the same bytes as the Vault but its recorded size differs. It creates a child Metadata revision with the decoded byte size while preserving the body, Chunks, path, and timestamps. The action rechecks the selected revision; if the database has advanced, inspect the file again. - **Store Vault file as a child of this revision** preserves the current Vault bytes on that selected branch. - **Retry reading revision** retries configured chunk retrieval without changing the revision tree. - **Apply logical deletion to Vault**, **Discard this branch**, and **Discard unreadable revision** are destructive decisions. Use them only after preserving every version which may still be needed. diff --git a/docs/settings.md b/docs/settings.md index 77493609..d6f8b252 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -963,7 +963,7 @@ Recreate chunks from files currently present in the Vault. This can repair missi Compare each Vault file with every current leaf revision in the local database. Each winner and conflict revision is shown separately with its exact revision identifier, local chunk availability, and relationship to the current Vault file. Unavailable shared ancestors are reported separately because they prevent conservative three-way merging but are not current leaves which can be discarded. -Select **Begin inspection** to run the inspection. Each reported file and current leaf revision has a wrench menu for read-only comparison, applying an exact database revision to the Vault, recording an exact byte match, preserving the Vault file as a child of a selected branch, retrying chunk retrieval, or explicitly discarding a branch. Destructive actions require confirmation. Follow [Recover a conflicted or mismatched file](recovery.md#recover-a-conflicted-or-mismatched-file) before changing revision history. +Select **Begin inspection** to run the inspection. Each reported file and current leaf revision has a wrench menu for read-only comparison, applying an exact database revision to the Vault, recording an exact byte match, correcting an incorrect recorded size when the readable revision exactly matches the Vault and has no conflict, preserving the Vault file as a child of a selected branch, retrying chunk retrieval, or explicitly discarding a branch. Size correction preserves the content and file timestamps and rechecks the selected revision before writing. Destructive actions require confirmation. Follow [Recover a conflicted or mismatched file](recovery.md#recover-a-conflicted-or-mismatched-file) before changing revision history. The same inspection also reports local Metadata whose stored document ID does not agree with its recorded path. A stale entry does not suppress ordinary inspection when consistently addressed Metadata can still be resolved for that logical path; otherwise, the unresolved path is excluded from ordinary file-repair actions. When the current winner has no conflict leaves and has an unambiguous target, its wrench menu offers a separately confirmed, one-entry repair. The target is derived from the current local file-name case and path obfuscation settings, then written and verified before the obsolete ID is removed. Ambiguous, conflicted, deleted, excluded, or otherwise unsafe entries remain read-only. This action does not rename Vault files or folders. Follow [Repair a Metadata document ID mismatch](recovery.md#repair-a-metadata-document-id-mismatch) for the complete backup, repair, propagation, and verification procedure. For widespread naming differences across devices, use that guide to choose an authoritative Vault, correct its storage names while Obsidian is closed, rebuild the central remote, and reset the other devices. diff --git a/docs/specs_conflict_resolution.md b/docs/specs_conflict_resolution.md index 6f29fedb..36678cb4 100644 --- a/docs/specs_conflict_resolution.md +++ b/docs/specs_conflict_resolution.md @@ -89,6 +89,8 @@ Each reported current leaf revision has a compact wrench menu. The available act Every mutating action rechecks that the selected revision is still a current leaf. If another operation resolved or replaced it, the action fails and the card is refreshed instead of extending an obsolete branch. +The inspection also offers **Correct recorded file size** when the conflict-free current winner is readable, its decoded bytes match the Vault exactly, and its recorded size differs from the decoded byte length. This creates a child Metadata revision which changes only the recorded size while retaining the content references and file timestamps. The operation rechecks the current revision before writing and records the resulting byte match as Vault provenance. Size and timestamps diagnose this condition; exact bytes establish that the Vault and database contain the same file. + The card uses compact, mobile-friendly diagnostic rows with an emoji and a text label. `🧩 Missing chunks: N` identifies an unreadable revision. In the database row, `Δsize` is decoded size minus recorded size; in the Vault row, `Δsize vs DB` is Vault size minus decoded database size. `Δtime` is Vault modification time minus database modification time. The ordinary two-second comparison window still labels which side is newer. These values help diagnose a mismatch; path, size, and modification time do not prove revision identity or decide which content should win. A shared ancestor is informational. An ancestor which is no longer a current leaf cannot be discarded independently through this workflow. If its body is unavailable, conservative three-way merge remains disabled, although readable current leaf revisions can still be selected manually. diff --git a/src/common/messages/LiveSyncProvisionalMessages.ts b/src/common/messages/LiveSyncProvisionalMessages.ts index ae63c87c..e0bc027e 100644 --- a/src/common/messages/LiveSyncProvisionalMessages.ts +++ b/src/common/messages/LiveSyncProvisionalMessages.ts @@ -280,6 +280,7 @@ export const liveSyncProvisionalEnglishMessages = { "Apply database revision to Vault": "Apply database revision to Vault", "Mark this revision as the Vault version": "Mark this revision as the Vault version", "Store Vault file as a child of this revision": "Store Vault file as a child of this revision", + "Correct recorded file size": "Correct recorded file size", "Apply logical deletion to Vault": "Apply logical deletion to Vault", "Apply logical deletion ${REVISION} to ${FILE}? The current Vault file will be removed.": "Apply logical deletion ${REVISION} to ${FILE}? The current Vault file will be removed.", diff --git a/src/modules/features/SettingDialogue/PaneHatch.ts b/src/modules/features/SettingDialogue/PaneHatch.ts index 48f6e4de..46425648 100644 --- a/src/modules/features/SettingDialogue/PaneHatch.ts +++ b/src/modules/features/SettingDialogue/PaneHatch.ts @@ -32,12 +32,14 @@ import { chooseAndCopyFileDatabaseInfo, collectFileDatabaseInfoPaths, copyFileDatabaseInfo, + inspectFileDatabaseInfo, retryReadFileDatabaseRevision, } from "@/serviceFeatures/fileDatabaseInfo.ts"; import { discardLiveBranch, discardUnreadableLiveRevision, inspectFileRepair, + repairRevisionSize, type FileRepairInspection, type FileRepairRevision, } from "@/serviceFeatures/fileRepair.ts"; @@ -786,6 +788,31 @@ export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement, }, }); } + if (metadata.revision && policy.repairSize) { + revisionActions.push({ + title: $msg("Correct recorded file size"), + run: async () => { + await runMutation( + `Correct the recorded size on database revision ${metadata.revision}`, + async () => { + const result = await repairRevisionSize( + this.core, + path, + metadata.revision! + ); + if (result !== "repaired" && result !== "unchanged") { + return false; + } + const current = await inspectFileDatabaseInfo(this.core, path); + const currentRevision = current.database.currentRevision; + return currentRevision !== null + ? storeStorageOnRevision(path, currentRevision, false) + : false; + } + ); + }, + }); + } if (metadata.revision && policy.storeVaultOnBranch) { revisionActions.push({ title: $msg("Store Vault file as a child of this revision"), diff --git a/src/serviceFeatures/fileRepair.ts b/src/serviceFeatures/fileRepair.ts index 64172843..8194ff8c 100644 --- a/src/serviceFeatures/fileRepair.ts +++ b/src/serviceFeatures/fileRepair.ts @@ -1,3 +1,4 @@ +import { isNotFoundError } from "@vrtmrz/livesync-commonlib/compat/common/utils.doc"; import type { LoadedEntry } from "@vrtmrz/livesync-commonlib/compat/common/types"; import { createBlob, isDocContentSame, readAsBlob } from "@vrtmrz/livesync-commonlib/compat/common/utils"; import type { StorageAccess } from "@vrtmrz/livesync-commonlib/compat/interfaces/StorageAccess"; @@ -12,6 +13,8 @@ import { export type FileRepairCore = FileDatabaseInfoCore & { fileHandler: Pick; + localDatabase: FileDatabaseInfoCore["localDatabase"] & + Pick; storageAccess: FileDatabaseInfoCore["storageAccess"] & Pick; }; @@ -29,11 +32,7 @@ export type FileRepairInspection = { requiresAttention: boolean; }; -export type DiscardUnreadableRevisionResult = - | "discarded" - | "failed" - | "no-longer-live" - | "revision-is-readable"; +export type DiscardUnreadableRevisionResult = "discarded" | "failed" | "no-longer-live" | "revision-is-readable"; export type DiscardLiveBranchResult = "discarded" | "failed" | "no-longer-live" | "only-live-revision"; @@ -67,9 +66,7 @@ export async function inspectFileRepair(core: FileRepairCore, path: string): Pro const winnerRepresentsStoredFile = winner !== undefined && !winner.metadata.deleted; const databaseAndStorageDiffer = information.storage.exists !== winnerRepresentsStoredFile || - (information.storage.exists && - winnerRepresentsStoredFile && - winner.contentMatchesStorage === false); + (information.storage.exists && winnerRepresentsStoredFile && winner.contentMatchesStorage === false); const unreadableLiveRevision = information.database.unavailableConflictRevisions.length > 0 || revisions.some(({ contentReadable }) => !contentReadable); @@ -77,6 +74,10 @@ export async function inspectFileRepair(core: FileRepairCore, path: string): Pro databaseAndStorageDiffer || information.database.conflictCount > 0 || unreadableLiveRevision || + revisions.some( + ({ loadedEntry, metadata }) => + loadedEntry !== false && readAsBlob(loadedEntry).size !== metadata.recordedSize + ) || (information.database.exists && winner === undefined); return { @@ -92,10 +93,9 @@ export async function discardUnreadableLiveRevision( revision: string ): Promise { const latest = await inspectFileDatabaseInfo(core, path); - const liveRevisions = [ - latest.database.currentRevision, - ...latest.database.conflictRevisions, - ].filter((candidate): candidate is string => candidate !== null); + const liveRevisions = [latest.database.currentRevision, ...latest.database.conflictRevisions].filter( + (candidate): candidate is string => candidate !== null + ); if (!liveRevisions.includes(revision)) { return "no-longer-live"; } @@ -116,10 +116,9 @@ export async function discardLiveBranch( revision: string ): Promise { const latest = await inspectFileDatabaseInfo(core, path); - const liveRevisions = [ - latest.database.currentRevision, - ...latest.database.conflictRevisions, - ].filter((candidate): candidate is string => candidate !== null); + const liveRevisions = [latest.database.currentRevision, ...latest.database.conflictRevisions].filter( + (candidate): candidate is string => candidate !== null + ); if (!liveRevisions.includes(revision)) { return "no-longer-live"; } @@ -130,3 +129,74 @@ export async function discardLiveBranch( const deleted = await core.fileHandler.deleteRevisionFromDB(latest.databasePath, revision); return deleted ? "discarded" : "failed"; } + +export function canRepairRevisionSize(inspection: FileRepairInspection, revision: FileRepairRevision): boolean { + return ( + revision.role === "winner" && + revision.metadata.current && + revision.metadata.revision !== null && + revision.metadata.revision === inspection.information.database.currentRevision && + revision.contentReadable && + !revision.metadata.deleted && + revision.metadata.storageLayout === "chunked" && + inspection.information.database.conflictCount === 0 && + inspection.information.database.unavailableConflictRevisions.length === 0 && + inspection.information.storage.exists && + revision.contentMatchesStorage === true && + revision.loadedEntry !== false && + readAsBlob(revision.loadedEntry).size !== revision.metadata.recordedSize + ); +} + +export async function repairRevisionSize( + core: FileRepairCore, + path: string, + revision: string +): Promise<"repaired" | "unchanged" | "stale" | "unsafe" | "failed"> { + const inspection = await inspectFileRepair(core, path); + if (inspection.information.database.currentRevision !== revision) return "stale"; + const winner = inspection.revisions.find((candidate) => candidate.role === "winner"); + if (!winner || winner.loadedEntry === false) return "unsafe"; + if (!canRepairRevisionSize(inspection, winner)) { + return !winner.metadata.deleted && + winner.contentMatchesStorage === true && + inspection.information.database.conflictCount === 0 && + readAsBlob(winner.loadedEntry).size === winner.metadata.recordedSize + ? "unchanged" + : "unsafe"; + } + const content = readAsBlob(winner.loadedEntry); + const raw = await core.localDatabase.localDatabase + .get(winner.metadata.documentId, { conflicts: true }) + .catch((error: unknown) => { + if (isNotFoundError(error)) return false as const; + throw error; + }); + if (raw === false) return "stale"; + if ( + (raw.type !== "plain" && raw.type !== "newnote") || + raw._rev !== revision || + raw._deleted || + ("deleted" in raw && raw.deleted) || + raw._conflicts?.length + ) + return "stale"; + if ( + !(await core.storageAccess.isExistsIncludeHidden(path)) || + !(await isDocContentSame(content, createBlob(await core.storageAccess.readHiddenFileBinary(path)))) + ) { + return "unsafe"; + } + // Preserve the exact stored document; loaded content must never be embedded in Metadata. + const corrected = { ...raw, size: content.size }; + delete corrected._conflicts; + delete corrected._revs_info; + delete corrected._revisions; + try { + const result = await core.localDatabase.putRaw(corrected); + return result.ok ? "repaired" : "failed"; + } catch (error) { + if ((error as { status?: number }).status === 409) return "stale"; + throw error; + } +} diff --git a/src/serviceFeatures/fileRepair.unit.spec.ts b/src/serviceFeatures/fileRepair.unit.spec.ts index af12a34b..5d319dc1 100644 --- a/src/serviceFeatures/fileRepair.unit.spec.ts +++ b/src/serviceFeatures/fileRepair.unit.spec.ts @@ -1,9 +1,8 @@ +import PouchDB from "pouchdb-core"; +import MemoryAdapter from "pouchdb-adapter-memory"; +PouchDB.plugin(MemoryAdapter); import { describe, expect, it, vi } from "vitest"; -import { - discardLiveBranch, - discardUnreadableLiveRevision, - inspectFileRepair, -} from "./fileRepair"; +import { discardLiveBranch, discardUnreadableLiveRevision, inspectFileRepair, repairRevisionSize } from "./fileRepair"; function createCore() { const current = { @@ -48,6 +47,7 @@ function createCore() { getFilesIncludeHidden: vi.fn(async () => ["note.md"]), }, localDatabase: { + putRaw: vi.fn(async (_doc: unknown) => ({ ok: true, id: "f:note", rev: "4-repaired" })), localDatabase: { get: vi.fn(async (_id: string, options?: { rev?: string }) => options?.rev === "2-conflict" ? conflict : current @@ -94,6 +94,20 @@ function createCore() { } describe("file repair inspection", () => { + it("reports a size-only mismatch even when Vault bytes match", async () => { + const { core, current } = createCore(); + current._conflicts = []; + current.size = 45; + expect((await inspectFileRepair(core as never, "note.md")).requiresAttention).toBe(true); + }); + + it("omits matching bytes with correct size despite differing timestamps", async () => { + const { core, current } = createCore(); + current._conflicts = []; + current.mtime = 9000; + expect((await inspectFileRepair(core as never, "note.md")).requiresAttention).toBe(false); + }); + it("shows the winner and every conflict revision independently", async () => { const { core } = createCore(); @@ -146,12 +160,10 @@ describe("file repair inspection", () => { it("rechecks liveness and readability before discarding an exact revision", async () => { const { core, deleteRevisionFromDB } = createCore(); - await expect( - discardUnreadableLiveRevision(core as never, "note.md", "2-conflict") - ).resolves.toBe("discarded"); - await expect( - discardUnreadableLiveRevision(core as never, "note.md", "3-current") - ).resolves.toBe("revision-is-readable"); + await expect(discardUnreadableLiveRevision(core as never, "note.md", "2-conflict")).resolves.toBe("discarded"); + await expect(discardUnreadableLiveRevision(core as never, "note.md", "3-current")).resolves.toBe( + "revision-is-readable" + ); expect(deleteRevisionFromDB).toHaveBeenCalledOnce(); expect(deleteRevisionFromDB).toHaveBeenCalledWith("note.md", "2-conflict"); @@ -175,9 +187,7 @@ describe("file repair inspection", () => { }), }), ]); - await expect( - discardUnreadableLiveRevision(core as never, "note.md", "1-root") - ).resolves.toBe("discarded"); + await expect(discardUnreadableLiveRevision(core as never, "note.md", "1-root")).resolves.toBe("discarded"); expect(deleteRevisionFromDB).toHaveBeenCalledWith("note.md", "1-root"); }); @@ -188,9 +198,9 @@ describe("file repair inspection", () => { _conflicts: [], }); - await expect( - discardUnreadableLiveRevision(core as never, "note.md", "2-conflict") - ).resolves.toBe("no-longer-live"); + await expect(discardUnreadableLiveRevision(core as never, "note.md", "2-conflict")).resolves.toBe( + "no-longer-live" + ); expect(deleteRevisionFromDB).not.toHaveBeenCalled(); }); @@ -198,9 +208,7 @@ describe("file repair inspection", () => { it("discards an exact readable winner while another live branch remains", async () => { const { core, deleteRevisionFromDB } = createCore(); - await expect( - discardLiveBranch(core as never, "note.md", "3-current") - ).resolves.toBe("discarded"); + await expect(discardLiveBranch(core as never, "note.md", "3-current")).resolves.toBe("discarded"); expect(deleteRevisionFromDB).toHaveBeenCalledWith("note.md", "3-current"); }); @@ -209,9 +217,7 @@ describe("file repair inspection", () => { const { core, current, deleteRevisionFromDB } = createCore(); current._conflicts = []; - await expect( - discardLiveBranch(core as never, "note.md", "3-current") - ).resolves.toBe("only-live-revision"); + await expect(discardLiveBranch(core as never, "note.md", "3-current")).resolves.toBe("only-live-revision"); expect(deleteRevisionFromDB).not.toHaveBeenCalled(); }); @@ -219,10 +225,112 @@ describe("file repair inspection", () => { it("refuses to discard a branch which is no longer live", async () => { const { core, deleteRevisionFromDB } = createCore(); - await expect( - discardLiveBranch(core as never, "note.md", "1-stale") - ).resolves.toBe("no-longer-live"); + await expect(discardLiveBranch(core as never, "note.md", "1-stale")).resolves.toBe("no-longer-live"); expect(deleteRevisionFromDB).not.toHaveBeenCalled(); }); }); + +describe("recorded size repair", () => { + function repairFixture() { + const fixture = createCore(); + fixture.current._conflicts = []; + fixture.current.size = 45; + return fixture; + } + it("writes a child changing only size and omitting response metadata", async () => { + const { core, current } = repairFixture(); + expect(await repairRevisionSize(core as never, "note.md", current._rev)).toBe("repaired"); + const corrected = core.localDatabase.putRaw.mock.calls[0][0]; + const expected = { ...current, size: 7 } as Partial; + delete expected._conflicts; + delete expected._revs_info; + expect(corrected).toEqual(expected); + }); + it.each(["conflict", "different-body", "missing-chunk", "deleted", "absent-vault"])("refuses %s", async (state) => { + const { core, current } = repairFixture(); + if (state === "conflict") current._conflicts = ["2-conflict"]; + if (state === "different-body") + core.storageAccess.readHiddenFileBinary.mockResolvedValue(new TextEncoder().encode("edit").buffer); + if (state === "missing-chunk") core.localDatabase.allDocsRaw.mockResolvedValue({ rows: [] }); + if (state === "deleted") current.deleted = true; + if (state === "absent-vault") core.storageAccess.isExistsIncludeHidden.mockResolvedValue(false); + expect(await repairRevisionSize(core as never, "note.md", current._rev)).toBe("unsafe"); + expect(core.localDatabase.putRaw).not.toHaveBeenCalled(); + }); + it("refuses a selected revision replaced before inspection", async () => { + const { core } = repairFixture(); + expect(await repairRevisionSize(core as never, "note.md", "2-old")).toBe("stale"); + expect(core.localDatabase.putRaw).not.toHaveBeenCalled(); + }); + it("refuses a revision advancing between inspection and final metadata read", async () => { + const { core, current } = repairFixture(); + const read = core.localDatabase.localDatabase.get; + read.mockResolvedValueOnce(current) + .mockResolvedValueOnce(current) + .mockResolvedValueOnce({ ...current, _rev: "4-advanced" }); + expect(await repairRevisionSize(core as never, "note.md", current._rev)).toBe("stale"); + expect(core.localDatabase.putRaw).not.toHaveBeenCalled(); + }); + it("refuses a Vault edit after inspection", async () => { + const { core, current } = repairFixture(); + core.storageAccess.readHiddenFileBinary + .mockResolvedValueOnce(new TextEncoder().encode("current").buffer) + .mockResolvedValueOnce(new TextEncoder().encode("new edit").buffer); + expect(await repairRevisionSize(core as never, "note.md", current._rev)).toBe("unsafe"); + expect(core.localDatabase.putRaw).not.toHaveBeenCalled(); + }); + it("treats a document removed after inspection as stale", async () => { + const { core, current } = repairFixture(); + core.localDatabase.localDatabase.get + .mockResolvedValueOnce(current) + .mockResolvedValueOnce(current) + .mockRejectedValueOnce({ status: 404 }); + expect(await repairRevisionSize(core as never, "note.md", current._rev)).toBe("stale"); + expect(core.localDatabase.putRaw).not.toHaveBeenCalled(); + }); + it("refuses advancement during the last Vault read through CAS", async () => { + const { core, current } = repairFixture(); + core.localDatabase.putRaw.mockRejectedValue({ status: 409 }); + expect(await repairRevisionSize(core as never, "note.md", current._rev)).toBe("stale"); + }); + it("does not repeat a correction or change mtime for matching content", async () => { + const { core, current } = repairFixture(); + current.size = 7; + expect(await repairRevisionSize(core as never, "note.md", current._rev)).toBe("unchanged"); + expect(core.localDatabase.putRaw).not.toHaveBeenCalled(); + }); +}); + +describe("size repair revision ancestry", () => { + it("preserves metadata and chunks in a real PouchDB child and is idempotent", async () => { + const { core, current } = createCore(); + const database = new PouchDB(`size-repair-${crypto.randomUUID()}`, { adapter: "memory" }); + try { + const metadata = { ...current } as Partial & { _id: string }; + delete metadata._rev; + delete metadata._conflicts; + delete metadata._revs_info; + const stored = await database.put({ ...metadata, size: 45 }); + core.localDatabase.localDatabase = database as never; + core.localDatabase.putRaw.mockImplementation((doc) => database.put(doc as never)); + core.localDatabase.getDBEntryFromMeta.mockImplementation(async (meta) => ({ ...meta, data: ["current"] })); + expect(await repairRevisionSize(core as never, "note.md", stored.rev)).toBe("repaired"); + const corrected = (await database.get(current._id, { revs: true })) as typeof current & { + _revisions: { ids: string[] }; + }; + expect(corrected.size).toBe(7); + expect(corrected.children).toEqual(current.children); + expect(corrected.ctime).toBe(current.ctime); + expect(corrected.mtime).toBe(current.mtime); + expect(corrected.path).toBe(current.path); + expect(corrected).not.toHaveProperty("data"); + expect(corrected._revisions.ids[1]).toBe(stored.rev.split("-")[1]); + expect(await repairRevisionSize(core as never, "note.md", corrected._rev)).toBe("unchanged"); + expect((await inspectFileRepair(core as never, "note.md")).requiresAttention).toBe(false); + expect(core.localDatabase.putRaw).toHaveBeenCalledOnce(); + } finally { + await database.destroy(); + } + }); +}); diff --git a/src/serviceFeatures/fileRepairPresentation.ts b/src/serviceFeatures/fileRepairPresentation.ts index 4906f4cb..c397956b 100644 --- a/src/serviceFeatures/fileRepairPresentation.ts +++ b/src/serviceFeatures/fileRepairPresentation.ts @@ -8,10 +8,7 @@ import { readAsBlob, } from "@vrtmrz/livesync-commonlib/compat/common/utils"; import { isPlainText } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path"; -import type { - FileRepairInspection, - FileRepairRevision, -} from "./fileRepair"; +import { canRepairRevisionSize, type FileRepairInspection, type FileRepairRevision } from "./fileRepair"; export type FileRepairRevisionActions = { compareWithVault: boolean; @@ -22,6 +19,7 @@ export type FileRepairRevisionActions = { retryRevision: boolean; discardBranch: boolean; discardRevision: boolean; + repairSize: boolean; }; export type FileRepairTimestampRelation = @@ -87,6 +85,7 @@ export function getFileRepairRevisionActions( !hasConflictBranches && !revision.metadata.deleted && !revision.contentReadable, + repairSize: canRepairRevisionSize(inspection, revision), }; } diff --git a/src/serviceFeatures/fileRepairPresentation.unit.spec.ts b/src/serviceFeatures/fileRepairPresentation.unit.spec.ts index 1f5f968c..6a8d1c32 100644 --- a/src/serviceFeatures/fileRepairPresentation.unit.spec.ts +++ b/src/serviceFeatures/fileRepairPresentation.unit.spec.ts @@ -87,6 +87,7 @@ describe("file repair presentation", () => { retryRevision: false, discardRevision: false, discardBranch: true, + repairSize: false, }); }); @@ -104,6 +105,58 @@ describe("file repair presentation", () => { }); }); + it("offers size correction for a readable, matching, conflict-free winner", () => { + const { inspection, revision } = createInspection({ + role: "winner", + metadata: { + ...createInspection().revision.metadata, + current: true, + recordedSize: 9, + }, + contentMatchesStorage: true, + }); + inspection.information.database.conflictCount = 0; + inspection.information.database.conflictRevisions = []; + inspection.information.database.currentRevision = revision.metadata.revision; + inspection.information.storage.size = 7; + + expect(getFileRepairRevisionActions(inspection, revision)).toMatchObject({ + repairSize: true, + }); + }); + + it("does not offer size correction for unsafe or already-correct revisions", () => { + const conflict = createInspection({ contentMatchesStorage: true }); + expect(getFileRepairRevisionActions(conflict.inspection, conflict.revision)).toMatchObject({ + repairSize: false, + }); + + const unreadable = createInspection({ + role: "winner", + contentReadable: false, + contentMatchesStorage: true, + }); + unreadable.inspection.information.database.conflictCount = 0; + unreadable.inspection.information.database.conflictRevisions = []; + unreadable.inspection.information.storage.size = 7; + unreadable.revision.metadata.current = true; + unreadable.inspection.information.database.currentRevision = unreadable.revision.metadata.revision; + expect(getFileRepairRevisionActions(unreadable.inspection, unreadable.revision)).toMatchObject({ + repairSize: false, + }); + + const unchanged = createInspection({ + role: "winner", + contentMatchesStorage: true, + }); + unchanged.inspection.information.database.conflictCount = 0; + unchanged.inspection.information.database.conflictRevisions = []; + unchanged.inspection.information.database.currentRevision = unchanged.revision.metadata.revision; + expect(getFileRepairRevisionActions(unchanged.inspection, unchanged.revision)).toMatchObject({ + repairSize: false, + }); + }); + it("does not offer a text comparison for a binary file", () => { const { inspection, revision } = createInspection(); inspection.information.path = "image.png"; @@ -191,6 +244,16 @@ describe("file repair presentation", () => { }); }); + it("does not offer size correction for legacy inline content", () => { + const { inspection, revision } = createInspection({ role: "winner", contentMatchesStorage: true }); + revision.metadata.current = true; + revision.metadata.storageLayout = "legacy-inline"; + inspection.information.database.currentRevision = revision.metadata.revision; + inspection.information.database.conflictCount = 0; + inspection.information.database.conflictRevisions = []; + expect(getFileRepairRevisionActions(inspection, revision).repairSize).toBe(false); + }); + it("reports recorded, decoded, Vault-size, and timestamp differences", () => { const { inspection, revision } = createInspection(); diff --git a/test/e2e-obsidian/scripts/revision-repair.ts b/test/e2e-obsidian/scripts/revision-repair.ts index 87586b2b..4b9bdba0 100644 --- a/test/e2e-obsidian/scripts/revision-repair.ts +++ b/test/e2e-obsidian/scripts/revision-repair.ts @@ -11,6 +11,8 @@ import { createTemporaryVault } from "../runner/vault.ts"; import type { Locator, Page } from "playwright"; const path = "revision-repair.md"; +const sizeRepairPath = "revision-size-repair.md"; +const sizeRepairContent = "Size repair fixture with multibyte text: 日本語\n"; const healthyDeletedPath = "healthy-logical-deletion.md"; const baseContent = "Revision repair\n\nShared base.\n"; const branchContents = [ @@ -35,6 +37,29 @@ type VaultWinnerState = { winnerRevision: string; }; +type SizeRepairFixture = { + revisionWithWrongSize: string; + expectedSize: number; + expectedPath: string; + expectedCtime: number; + expectedMtime: number; + expectedChunks: string[]; +}; + +type SizeRepairState = SizeRepairFixture & { + revision: string; + size: number; + path: string; + ctime: number; + mtime: number; + chunks: string[]; + bodyMatches: boolean; + chunksAvailable: boolean; + historyStart: number; + historyIds: string[]; + provenanceRevision: string | null; +}; + async function createAndOpenBaseFile(cliBinary: string, env: NodeJS.ProcessEnv): Promise { await evalObsidianJson( cliBinary, @@ -95,6 +120,68 @@ async function createHealthyLogicalDeletion(cliBinary: string, env: NodeJS.Proce ); } +async function createSizeRepairFixture(cliBinary: string, env: NodeJS.ProcessEnv): Promise { + await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + `const path=${JSON.stringify(sizeRepairPath)};`, + `const content=${JSON.stringify(sizeRepairContent)};`, + "const file=app.vault.getAbstractFileByPath(path)??await app.vault.create(path,content);", + "await app.workspace.getLeaf(false).openFile(file);", + "return JSON.stringify({ok:true});", + "})()", + ].join(""), + env + ); + const saved = await waitForLocalDatabaseEntry(cliBinary, env, sizeRepairPath); + return await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + `const path=${JSON.stringify(sizeRepairPath)};`, + `const expectedSize=new TextEncoder().encode(${JSON.stringify(sizeRepairContent)}).length;`, + `const savedRevision=${JSON.stringify(saved.rev)};`, + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "const id=await core.services.path.path2id(path);", + "const original=await core.localDatabase.localDatabase.get(id);", + "if(original._rev!==savedRevision) throw new Error('Size repair source changed before fixture setup');", + "const wrong=await core.localDatabase.putRaw({...original,size:expectedSize+7});", + "if(!wrong.ok) throw new Error('Could not create size-only Metadata fixture');", + "core.localDatabase.clearCaches();", + "const revision=await core.localDatabase.localDatabase.get(id);", + "return JSON.stringify({revisionWithWrongSize:revision._rev,expectedSize,expectedPath:path,expectedCtime:original.ctime,expectedMtime:original.mtime,expectedChunks:original.children??[]});", + "})()", + ].join(""), + env + ); +} + +async function readSizeRepairState(cliBinary: string, env: NodeJS.ProcessEnv): Promise { + return await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + `const path=${JSON.stringify(sizeRepairPath)};`, + `const expected=${JSON.stringify(sizeRepairContent)};`, + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "const id=await core.services.path.path2id(path);", + "const raw=await core.localDatabase.localDatabase.get(id,{revs:true});", + "const entry=await core.localDatabase.getDBEntry(path,undefined,false,true,true);", + "if(!entry) throw new Error('Corrected size repair entry cannot be decoded');", + "const body=await new Blob(Array.isArray(entry.data)?entry.data:[entry.data]).text();", + "const children=raw.children??[];", + "const rows=children.length?await core.localDatabase.allDocsRaw({keys:children}):{rows:[]};", + "const available=rows.rows.filter(row=>row.value?.rev&&!row.value?.deleted).length===children.length;", + "const provenance=await core.services.keyValueDB.openSimpleStore('file-reflection-provenance-v1').get(path);", + "const revisions=raw._revisions??{start:0,ids:[]};", + "return JSON.stringify({revision:raw._rev,size:raw.size,path:raw.path,ctime:raw.ctime,mtime:raw.mtime,chunks:children,bodyMatches:body===expected,chunksAvailable:available,historyStart:revisions.start,historyIds:revisions.ids,provenanceRevision:provenance?.revision??null});", + "})()", + ].join(""), + env + ); +} + async function createBrokenConflict( cliBinary: string, env: NodeJS.ProcessEnv, @@ -210,16 +297,16 @@ async function readFileReflectionProvenance( ); } -function repairCard(settings: Locator): Locator { - return settings.locator(".sls-repair-result").filter({ hasText: path }); +function repairCard(settings: Locator, targetPath = path): Locator { + return settings.locator(".sls-repair-result").filter({ hasText: targetPath }); } -function revisionCard(settings: Locator, revision: string): Locator { - return repairCard(settings).locator(".sls-repair-revision").filter({ hasText: revision }); +function revisionCard(settings: Locator, revision: string, targetPath = path): Locator { + return repairCard(settings, targetPath).locator(".sls-repair-revision").filter({ hasText: revision }); } -async function openRevisionActionMenu(page: Page, settings: Locator, revision: string): Promise { - const actionButton = revisionCard(settings, revision).getByRole("button", { +async function openRevisionActionMenu(page: Page, settings: Locator, revision: string, targetPath = path): Promise { + const actionButton = revisionCard(settings, revision, targetPath).getByRole("button", { name: `More actions for revision ${revision}`, exact: true, }); @@ -250,8 +337,8 @@ async function openRevisionActionMenu(page: Page, settings: Locator, revision: s return menu; } -async function selectRevisionAction(page: Page, settings: Locator, revision: string, action: string): Promise { - const menu = await openRevisionActionMenu(page, settings, revision); +async function selectRevisionAction(page: Page, settings: Locator, revision: string, action: string, targetPath = path): Promise { + const menu = await openRevisionActionMenu(page, settings, revision, targetPath); const item = menu.getByText(action, { exact: true }); await item.waitFor({ state: "visible", timeout: uiTimeoutMs }); await item.click({ timeout: uiTimeoutMs }); @@ -317,6 +404,7 @@ async function main(): Promise { await createAndOpenBaseFile(cliBinary, session.cliEnv); const base = await waitForLocalDatabaseEntry(cliBinary, session.cliEnv, path); const healthyDeletionRevision = await createHealthyLogicalDeletion(cliBinary, session.cliEnv); + const sizeRepairFixture = await createSizeRepairFixture(cliBinary, session.cliEnv); const fixture = await createBrokenConflict(cliBinary, session.cliEnv, base.rev); const healthyDeletionProvenance = await readFileReflectionProvenance( cliBinary, @@ -347,7 +435,8 @@ async function main(): Promise { ); } - await withObsidianPage(session.remoteDebuggingPort, async (page) => { + const sizeRepairPort = session.remoteDebuggingPort; + await withObsidianPage(sizeRepairPort, async (page) => { const settingsNavigator = await openLiveSyncSettings(page, uiTimeoutMs); const settings = await settingsNavigator.openPage("Hatch"); const verifySetting = settings.locator(".setting-item").filter({ @@ -694,8 +783,73 @@ async function main(): Promise { ); } + await withObsidianPage(sizeRepairPort, async (page) => { + const settingsNavigator = await openLiveSyncSettings(page, uiTimeoutMs); + const settings = await settingsNavigator.openPage("Hatch"); + const verifySetting = settings.locator(".setting-item").filter({ + has: settingsNavigator.page.getByText("Inspect conflicts and file/database differences", { + exact: true, + }), + }); + await verifySetting.getByRole("button", { name: "Begin inspection", exact: true }).click({ + timeout: uiTimeoutMs, + }); + const card = repairCard(settings, sizeRepairPath); + await card.waitFor({ state: "visible", timeout: uiTimeoutMs }); + const winner = revisionCard(settings, sizeRepairFixture.revisionWithWrongSize, sizeRepairPath); + await winner.getByText(/^📦 DB:.*Δsize/u).waitFor({ state: "visible", timeout: uiTimeoutMs }); + const menu = await openRevisionActionMenu( + page, + settings, + sizeRepairFixture.revisionWithWrongSize, + sizeRepairPath + ); + await menu.getByText("Correct recorded file size", { exact: true }).waitFor({ + state: "visible", + timeout: uiTimeoutMs, + }); + const screenshot = await captureObsidianElement( + sizeRepairPort, + "revision-repair-size-correction-actions.png", + (currentPage) => currentPage.locator(".menu:visible").last() + ); + console.log(`Size correction actions screenshot: ${screenshot}`); + await page.keyboard.press("Escape"); + await selectRevisionAction( + page, + settings, + sizeRepairFixture.revisionWithWrongSize, + "Correct recorded file size", + sizeRepairPath + ); + await card.waitFor({ state: "hidden", timeout: uiTimeoutMs }); + }); + const sizeRepairState = await readSizeRepairState(cliBinary, session.cliEnv); + const correctedRevisionGeneration = Number(sizeRepairState.revision.split("-")[0]); + const expectedParentId = sizeRepairFixture.revisionWithWrongSize.split("-").slice(1).join("-"); + if ( + sizeRepairState.revision === sizeRepairFixture.revisionWithWrongSize || + sizeRepairState.size !== sizeRepairFixture.expectedSize || + sizeRepairState.path !== sizeRepairFixture.expectedPath || + sizeRepairState.ctime !== sizeRepairFixture.expectedCtime || + sizeRepairState.mtime !== sizeRepairFixture.expectedMtime || + JSON.stringify(sizeRepairState.chunks) !== JSON.stringify(sizeRepairFixture.expectedChunks) || + !sizeRepairState.bodyMatches || + !sizeRepairState.chunksAvailable || + sizeRepairState.historyStart !== correctedRevisionGeneration || + sizeRepairState.historyIds[1] !== expectedParentId || + sizeRepairState.provenanceRevision !== sizeRepairState.revision + ) { + throw new Error( + `Size repair did not preserve the exact file body, Chunks, Metadata fields, ancestry, and provenance: ${JSON.stringify({ + fixture: sizeRepairFixture, + state: sizeRepairState, + })}` + ); + } + console.log( - "Real Obsidian omitted a healthy logical deletion; rendered each live revision with compact actions and diagnostics; showed that the Vault matched the winner while one conflict remained; compared and applied an exact readable revision without changing the tree; preserved Vault provenance; kept an unreadable branch through automatic checking, retry, and cancelled discard; and discarded only the selected branch after confirmation." + "Real Obsidian omitted a healthy logical deletion; rendered each live revision with compact actions and diagnostics; showed that the Vault matched the winner while one conflict remained; compared and applied an exact readable revision without changing the tree; preserved Vault provenance; kept an unreadable branch through automatic checking, retry, and cancelled discard; discarded only the selected branch after confirmation; and corrected a Vault-matching Metadata size through Verify while preserving its body, Chunks, timestamps, ancestry, and provenance." ); console.log(`Repair card screenshot: ${repairCardScreenshot}`); console.log(`Mobile-width repair card screenshot: ${mobileWidthScreenshot}`); diff --git a/updates.md b/updates.md index f24a9b73..21c1af91 100644 --- a/updates.md +++ b/updates.md @@ -12,6 +12,12 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi ## Unreleased +### Fixed + +#### Recovery + +- Verify reports a file whose recorded size differs from its readable content, and offers a guarded correction when that exact content matches the Vault and has no conflict. (#1324) + ### Improved #### P2P synchronisation From 6b18025594603e72bbd59e2eb07827920fa635c8 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Sat, 7 Nov 2026 00:57:55 +0000 Subject: [PATCH 2/3] fix: select published Commonlib 0.4.0 for file size preservation --- package-lock.json | 48 +++++++++++++++++++++++------------------------ package.json | 2 +- updates.md | 1 + 3 files changed, 26 insertions(+), 25 deletions(-) diff --git a/package-lock.json b/package-lock.json index f3e355e7..6616e73e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,7 @@ "@smithy/types": "^4.14.3", "@smithy/util-retry": "^4.4.5", "@vrtmrz/browser-ui-kit": "0.1.0", - "@vrtmrz/livesync-commonlib": "0.3.2", + "@vrtmrz/livesync-commonlib": "0.4.0", "@vrtmrz/obsidian-plugin-kit": "0.1.7", "@vrtmrz/ui-interactions": "0.1.2", "@vrtmrz/value-store": "0.1.0", @@ -1949,9 +1949,9 @@ } }, "node_modules/@noble/secp256k1": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@noble/secp256k1/-/secp256k1-3.1.0.tgz", - "integrity": "sha512-+F7iS7tUMaNGXcc9X3PjmjvuQnXEuSjCRNzVVA2xAcKXgCaP0dHYz4SFyt4FKNHef7sOP//xihowcySSS7PK9g==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@noble/secp256k1/-/secp256k1-3.2.0.tgz", + "integrity": "sha512-Z3ZAWOTxJ0EuTuZTi7Y69iK7GgrLHh8sgm45lVDhg/b3Nk1TpAiKhick2KkZisHuupeepSkyIydN/J459SdX1w==", "license": "MIT", "funding": { "url": "https://paulmillr.com/funding/" @@ -3281,6 +3281,22 @@ "vite": "^8.0.0-beta.7 || ^8.0.0" } }, + "node_modules/@trystero-p2p/core": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/@trystero-p2p/core/-/core-0.26.0.tgz", + "integrity": "sha512-CZpeXPgfbDdFKrGxfD98F9tn/fTjya4mQ2o/Ax6H0qpBaJXCBz5+jyPtjsYEPAAcKRK/btOatCetFiQ5qIMCOA==", + "license": "MIT" + }, + "node_modules/@trystero-p2p/nostr": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/@trystero-p2p/nostr/-/nostr-0.26.0.tgz", + "integrity": "sha512-HArp9fonBkqFZgw3U2D823fCePQv7haKkvjtwbGRkWzy+Wo9G3cCDEnG/GENDWwzERHrhRcRacpFsFt4JpwRJQ==", + "license": "MIT", + "dependencies": { + "@noble/secp256k1": "^3.2.0", + "@trystero-p2p/core": "0.26.0" + } + }, "node_modules/@tsconfig/svelte": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/@tsconfig/svelte/-/svelte-5.0.8.tgz", @@ -4065,9 +4081,9 @@ } }, "node_modules/@vrtmrz/livesync-commonlib": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.3.2.tgz", - "integrity": "sha512-PiKUdJUiBI4ot0AV1BjlkDoaY4Lbn9oTdEjL8FMX1qf2VCOwSnbsLoQMUr/45wmINpV31KWmqgr2zxqcYO+hpA==", + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.4.0.tgz", + "integrity": "sha512-2bhkrd0rRWSiy90rjOxpaO01Ei2V9VSwifzv4ePUttxrzt195zjWNZCzW9ZMvJvAtyPoifShilW0J4kig4oKFA==", "license": "MIT", "dependencies": { "@aws-crypto/sha256-js": "5.2.0", @@ -4077,7 +4093,7 @@ "@smithy/middleware-apply-body-checksum": "^4.3.9", "@smithy/types": "^4.14.3", "@smithy/util-retry": "^4.4.5", - "@trystero-p2p/nostr": "0.25.3", + "@trystero-p2p/nostr": "0.26.0", "@vrtmrz/value-store": "0.1.0", "diff-match-patch": "^1.0.5", "events": "^3.3.0", @@ -4115,22 +4131,6 @@ } } }, - "node_modules/@vrtmrz/livesync-commonlib/node_modules/@trystero-p2p/core": { - "version": "0.25.3", - "resolved": "https://registry.npmjs.org/@trystero-p2p/core/-/core-0.25.3.tgz", - "integrity": "sha512-lQKNq/ha+vF6kQZrpaXJGzlzxLF/Fhizoy5dwJUYQlkqRrbPup/Jov2EpPX/CPr2X+f79HxVzonkeni3MxmCuQ==", - "license": "MIT" - }, - "node_modules/@vrtmrz/livesync-commonlib/node_modules/@trystero-p2p/nostr": { - "version": "0.25.3", - "resolved": "https://registry.npmjs.org/@trystero-p2p/nostr/-/nostr-0.25.3.tgz", - "integrity": "sha512-nZV9Fl/GXuhIkJSQ+wIkdMoRLO1oSTUL/+vZorukaojeAdOpT/61e8b9Vzbt8L1ktMTaGPEJHw2BG/B5BCwBuw==", - "license": "MIT", - "dependencies": { - "@noble/secp256k1": "^3.1.0", - "@trystero-p2p/core": "0.25.3" - } - }, "node_modules/@vrtmrz/livesync-commonlib/node_modules/minimatch": { "version": "11.0.4", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-11.0.4.tgz", diff --git a/package.json b/package.json index 681ef357..0bcf3773 100644 --- a/package.json +++ b/package.json @@ -211,7 +211,7 @@ "@smithy/types": "^4.14.3", "@smithy/util-retry": "^4.4.5", "@vrtmrz/browser-ui-kit": "0.1.0", - "@vrtmrz/livesync-commonlib": "0.3.2", + "@vrtmrz/livesync-commonlib": "0.4.0", "@vrtmrz/obsidian-plugin-kit": "0.1.7", "@vrtmrz/ui-interactions": "0.1.2", "@vrtmrz/value-store": "0.1.0", diff --git a/updates.md b/updates.md index 21c1af91..4e83c9c8 100644 --- a/updates.md +++ b/updates.md @@ -16,6 +16,7 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi #### Recovery +- Files saved while host size information is stale retain the correct content size, preventing an incorrect size record from blocking reflection to other devices. (#1324) - Verify reports a file whose recorded size differs from its readable content, and offers a guarded correction when that exact content matches the Vault and has no conflict. (#1324) ### Improved From 623292d4659061dec710367ca29b131b6a2c664c Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Sat, 7 Nov 2026 02:35:16 +0000 Subject: [PATCH 3/3] docs: clarify recorded file size inspection and repair in settings --- docs/settings.md | 2 +- updates.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/settings.md b/docs/settings.md index d6f8b252..abf32307 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -963,7 +963,7 @@ Recreate chunks from files currently present in the Vault. This can repair missi Compare each Vault file with every current leaf revision in the local database. Each winner and conflict revision is shown separately with its exact revision identifier, local chunk availability, and relationship to the current Vault file. Unavailable shared ancestors are reported separately because they prevent conservative three-way merging but are not current leaves which can be discarded. -Select **Begin inspection** to run the inspection. Each reported file and current leaf revision has a wrench menu for read-only comparison, applying an exact database revision to the Vault, recording an exact byte match, correcting an incorrect recorded size when the readable revision exactly matches the Vault and has no conflict, preserving the Vault file as a child of a selected branch, retrying chunk retrieval, or explicitly discarding a branch. Size correction preserves the content and file timestamps and rechecks the selected revision before writing. Destructive actions require confirmation. Follow [Recover a conflicted or mismatched file](recovery.md#recover-a-conflicted-or-mismatched-file) before changing revision history. +Select **Begin inspection** to run the inspection. Each reported file and current leaf revision has a wrench menu for read-only comparison, applying an exact database revision to the Vault, recording an exact byte match, correcting an incorrect recorded file size, preserving the Vault file as a child of a selected branch, retrying chunk retrieval, or explicitly discarding a branch. Size correction is available when a readable revision exactly matches the Vault with no conflicts; it preserves the content and file timestamps and rechecks the selected revision before writing. Destructive actions require confirmation. Follow [Recover a conflicted or mismatched file](recovery.md#recover-a-conflicted-or-mismatched-file) before changing revision history. The same inspection also reports local Metadata whose stored document ID does not agree with its recorded path. A stale entry does not suppress ordinary inspection when consistently addressed Metadata can still be resolved for that logical path; otherwise, the unresolved path is excluded from ordinary file-repair actions. When the current winner has no conflict leaves and has an unambiguous target, its wrench menu offers a separately confirmed, one-entry repair. The target is derived from the current local file-name case and path obfuscation settings, then written and verified before the obsolete ID is removed. Ambiguous, conflicted, deleted, excluded, or otherwise unsafe entries remain read-only. This action does not rename Vault files or folders. Follow [Repair a Metadata document ID mismatch](recovery.md#repair-a-metadata-document-id-mismatch) for the complete backup, repair, propagation, and verification procedure. For widespread naming differences across devices, use that guide to choose an authoritative Vault, correct its storage names while Obsidian is closed, rebuild the central remote, and reset the other devices. diff --git a/updates.md b/updates.md index 4e83c9c8..2351998b 100644 --- a/updates.md +++ b/updates.md @@ -17,7 +17,7 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi #### Recovery - Files saved while host size information is stale retain the correct content size, preventing an incorrect size record from blocking reflection to other devices. (#1324) -- Verify reports a file whose recorded size differs from its readable content, and offers a guarded correction when that exact content matches the Vault and has no conflict. (#1324) +- **Inspect conflicts and file/database differences** reports files whose recorded size differs from their readable content, and offers a guarded **Correct recorded file size** action when that exact content matches the Vault without conflicts. (#1324) ### Improved