mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-11-14 20:20:12 +00:00
fix: inspect and repair incorrect recorded file sizes
This commit is contained in:
1 parent
fc5f02ca3b
commit
4a245debe7
11 files changed
+488
-57
No files matched your search
@@ -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.
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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<IFileHandler, "deleteRevisionFromDB">;
|
||||
localDatabase: FileDatabaseInfoCore["localDatabase"] &
|
||||
Pick<import("@vrtmrz/livesync-commonlib/compat/pouchdb/LiveSyncLocalDB").LiveSyncLocalDB, "putRaw">;
|
||||
storageAccess: FileDatabaseInfoCore["storageAccess"] & Pick<StorageAccess, "readHiddenFileBinary">;
|
||||
};
|
||||
|
||||
@@ -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<DiscardUnreadableRevisionResult> {
|
||||
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<DiscardLiveBranchResult> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<typeof current>;
|
||||
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<typeof current> & { _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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
await evalObsidianJson<unknown>(
|
||||
cliBinary,
|
||||
@@ -95,6 +120,68 @@ async function createHealthyLogicalDeletion(cliBinary: string, env: NodeJS.Proce
|
||||
);
|
||||
}
|
||||
|
||||
async function createSizeRepairFixture(cliBinary: string, env: NodeJS.ProcessEnv): Promise<SizeRepairFixture> {
|
||||
await evalObsidianJson<unknown>(
|
||||
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<SizeRepairFixture>(
|
||||
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<SizeRepairState> {
|
||||
return await evalObsidianJson<SizeRepairState>(
|
||||
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<Locator> {
|
||||
const actionButton = revisionCard(settings, revision).getByRole("button", {
|
||||
async function openRevisionActionMenu(page: Page, settings: Locator, revision: string, targetPath = path): Promise<Locator> {
|
||||
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<void> {
|
||||
const menu = await openRevisionActionMenu(page, settings, revision);
|
||||
async function selectRevisionAction(page: Page, settings: Locator, revision: string, action: string, targetPath = path): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
);
|
||||
}
|
||||
|
||||
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<void> {
|
||||
);
|
||||
}
|
||||
|
||||
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}`);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user