mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-08-28 22:37:08 +00:00
Improve recovery diagnostics and actions
This commit is contained in:
@@ -35,6 +35,8 @@ export type DiscardUnreadableRevisionResult =
|
||||
| "no-longer-live"
|
||||
| "revision-is-readable";
|
||||
|
||||
export type DiscardLiveBranchResult = "discarded" | "failed" | "no-longer-live" | "only-live-revision";
|
||||
|
||||
export async function inspectFileRepair(core: FileRepairCore, path: string): Promise<FileRepairInspection> {
|
||||
const information = await inspectFileDatabaseInfo(core, path);
|
||||
const storageContent = information.storage.exists
|
||||
@@ -62,12 +64,12 @@ export async function inspectFileRepair(core: FileRepairCore, path: string): Pro
|
||||
}
|
||||
|
||||
const winner = revisions.find(({ role }) => role === "winner");
|
||||
const winnerRepresentsStoredFile = winner !== undefined && !winner.metadata.deleted;
|
||||
const databaseAndStorageDiffer =
|
||||
information.storage.exists !== information.database.exists ||
|
||||
information.storage.exists !== winnerRepresentsStoredFile ||
|
||||
(information.storage.exists &&
|
||||
winner !== undefined &&
|
||||
(winner.metadata.deleted || winner.contentMatchesStorage === false)) ||
|
||||
(!information.storage.exists && winner !== undefined && !winner.metadata.deleted);
|
||||
winnerRepresentsStoredFile &&
|
||||
winner.contentMatchesStorage === false);
|
||||
const unreadableLiveRevision =
|
||||
information.database.unavailableConflictRevisions.length > 0 ||
|
||||
revisions.some(({ contentReadable }) => !contentReadable);
|
||||
@@ -107,3 +109,24 @@ export async function discardUnreadableLiveRevision(
|
||||
const deleted = await core.fileHandler.deleteRevisionFromDB(latest.databasePath, revision);
|
||||
return deleted ? "discarded" : "failed";
|
||||
}
|
||||
|
||||
export async function discardLiveBranch(
|
||||
core: FileRepairCore,
|
||||
path: string,
|
||||
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);
|
||||
if (!liveRevisions.includes(revision)) {
|
||||
return "no-longer-live";
|
||||
}
|
||||
if (liveRevisions.length < 2) {
|
||||
return "only-live-revision";
|
||||
}
|
||||
|
||||
const deleted = await core.fileHandler.deleteRevisionFromDB(latest.databasePath, revision);
|
||||
return deleted ? "discarded" : "failed";
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
discardLiveBranch,
|
||||
discardUnreadableLiveRevision,
|
||||
inspectFileRepair,
|
||||
} from "./fileRepair";
|
||||
@@ -16,6 +17,7 @@ function createCore() {
|
||||
size: 7,
|
||||
type: "plain",
|
||||
children: ["h:current"],
|
||||
deleted: false,
|
||||
eden: {},
|
||||
};
|
||||
const conflict = {
|
||||
@@ -118,6 +120,29 @@ describe("file repair inspection", () => {
|
||||
expect(inspection.requiresAttention).toBe(true);
|
||||
});
|
||||
|
||||
it("omits a logical deletion which already matches an absent Vault file", async () => {
|
||||
const { core, current } = createCore();
|
||||
current.deleted = true;
|
||||
current._conflicts = [];
|
||||
current.children = [];
|
||||
core.storageAccess.isExistsIncludeHidden.mockResolvedValue(false);
|
||||
core.storageAccess.statHidden.mockResolvedValue(null as never);
|
||||
|
||||
const inspection = await inspectFileRepair(core as never, "note.md");
|
||||
|
||||
expect(inspection.revisions).toEqual([
|
||||
expect.objectContaining({
|
||||
role: "winner",
|
||||
contentReadable: true,
|
||||
metadata: expect.objectContaining({
|
||||
deleted: true,
|
||||
revision: "3-current",
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
expect(inspection.requiresAttention).toBe(false);
|
||||
});
|
||||
|
||||
it("rechecks liveness and readability before discarding an exact revision", async () => {
|
||||
const { core, deleteRevisionFromDB } = createCore();
|
||||
|
||||
@@ -169,4 +194,35 @@ describe("file repair inspection", () => {
|
||||
|
||||
expect(deleteRevisionFromDB).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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");
|
||||
|
||||
expect(deleteRevisionFromDB).toHaveBeenCalledWith("note.md", "3-current");
|
||||
});
|
||||
|
||||
it("refuses to discard the only live branch", async () => {
|
||||
const { core, current, deleteRevisionFromDB } = createCore();
|
||||
current._conflicts = [];
|
||||
|
||||
await expect(
|
||||
discardLiveBranch(core as never, "note.md", "3-current")
|
||||
).resolves.toBe("only-live-revision");
|
||||
|
||||
expect(deleteRevisionFromDB).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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");
|
||||
|
||||
expect(deleteRevisionFromDB).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import {
|
||||
BASE_IS_NEW,
|
||||
EVEN,
|
||||
TARGET_IS_NEW,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/models/shared.const.symbols";
|
||||
import {
|
||||
compareMTime,
|
||||
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";
|
||||
|
||||
export type FileRepairRevisionActions = {
|
||||
compareWithVault: boolean;
|
||||
applyRevisionToVault: boolean;
|
||||
markAsVaultRevision: boolean;
|
||||
storeVaultOnBranch: boolean;
|
||||
applyLogicalDeletionToVault: boolean;
|
||||
retryRevision: boolean;
|
||||
discardBranch: boolean;
|
||||
discardRevision: boolean;
|
||||
};
|
||||
|
||||
export type FileRepairTimestampRelation =
|
||||
| "vault-newer"
|
||||
| "database-newer"
|
||||
| "same-window"
|
||||
| "unavailable";
|
||||
|
||||
export type FileRepairRevisionComparison = {
|
||||
recordedSize: number;
|
||||
decodedSize: number | null;
|
||||
recordedToDecodedSizeDifference: number | null;
|
||||
vaultSize: number | null;
|
||||
databaseToVaultSizeDifference: number | null;
|
||||
databaseMtime: number;
|
||||
vaultMtime: number | null;
|
||||
timestampDifferenceMs: number | null;
|
||||
timestampRelation: FileRepairTimestampRelation;
|
||||
};
|
||||
|
||||
export function getFileRepairRevisionActions(
|
||||
inspection: FileRepairInspection,
|
||||
revision: FileRepairRevision
|
||||
): FileRepairRevisionActions {
|
||||
const storageExists = inspection.information.storage.exists;
|
||||
const hasRevision = revision.metadata.revision !== null;
|
||||
const readableFileRevision =
|
||||
!revision.metadata.deleted &&
|
||||
revision.contentReadable &&
|
||||
revision.loadedEntry !== false;
|
||||
const matchesVault = storageExists && revision.contentMatchesStorage === true;
|
||||
const hasConflictBranches = inspection.information.database.conflictCount > 0;
|
||||
|
||||
return {
|
||||
compareWithVault:
|
||||
readableFileRevision &&
|
||||
storageExists &&
|
||||
revision.contentMatchesStorage === false &&
|
||||
isPlainText(inspection.information.path),
|
||||
applyRevisionToVault:
|
||||
hasRevision &&
|
||||
readableFileRevision &&
|
||||
(!storageExists || revision.contentMatchesStorage !== true),
|
||||
markAsVaultRevision:
|
||||
hasRevision &&
|
||||
readableFileRevision &&
|
||||
matchesVault,
|
||||
storeVaultOnBranch:
|
||||
hasRevision &&
|
||||
storageExists &&
|
||||
revision.contentMatchesStorage !== true,
|
||||
applyLogicalDeletionToVault:
|
||||
hasRevision &&
|
||||
revision.metadata.deleted &&
|
||||
storageExists,
|
||||
retryRevision:
|
||||
hasRevision &&
|
||||
!revision.metadata.deleted &&
|
||||
!revision.contentReadable,
|
||||
discardBranch: hasRevision && hasConflictBranches,
|
||||
discardRevision:
|
||||
hasRevision &&
|
||||
!hasConflictBranches &&
|
||||
!revision.metadata.deleted &&
|
||||
!revision.contentReadable,
|
||||
};
|
||||
}
|
||||
|
||||
export function getFileRepairRevisionComparison(
|
||||
inspection: FileRepairInspection,
|
||||
revision: FileRepairRevision
|
||||
): FileRepairRevisionComparison {
|
||||
const decodedSize =
|
||||
revision.loadedEntry === false
|
||||
? null
|
||||
: readAsBlob(revision.loadedEntry).size;
|
||||
const vaultSize =
|
||||
inspection.information.storage.exists
|
||||
? (inspection.information.storage.size ?? null)
|
||||
: null;
|
||||
const databaseMtime = revision.metadata.mtime;
|
||||
const vaultMtime =
|
||||
inspection.information.storage.exists
|
||||
? (inspection.information.storage.mtime ?? null)
|
||||
: null;
|
||||
const timestampDifferenceMs =
|
||||
databaseMtime > 0 && vaultMtime !== null && vaultMtime > 0
|
||||
? vaultMtime - databaseMtime
|
||||
: null;
|
||||
let timestampRelation: FileRepairTimestampRelation = "unavailable";
|
||||
if (timestampDifferenceMs !== null) {
|
||||
const comparison = compareMTime(vaultMtime!, databaseMtime);
|
||||
timestampRelation =
|
||||
comparison === EVEN
|
||||
? "same-window"
|
||||
: comparison === BASE_IS_NEW
|
||||
? "vault-newer"
|
||||
: comparison === TARGET_IS_NEW
|
||||
? "database-newer"
|
||||
: "unavailable";
|
||||
}
|
||||
|
||||
return {
|
||||
recordedSize: revision.metadata.recordedSize,
|
||||
decodedSize,
|
||||
recordedToDecodedSizeDifference:
|
||||
decodedSize === null
|
||||
? null
|
||||
: decodedSize - revision.metadata.recordedSize,
|
||||
vaultSize,
|
||||
databaseToVaultSizeDifference:
|
||||
decodedSize === null || vaultSize === null
|
||||
? null
|
||||
: vaultSize - decodedSize,
|
||||
databaseMtime,
|
||||
vaultMtime,
|
||||
timestampDifferenceMs,
|
||||
timestampRelation,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { FileRepairInspection, FileRepairRevision } from "./fileRepair";
|
||||
import {
|
||||
getFileRepairRevisionActions,
|
||||
getFileRepairRevisionComparison,
|
||||
} from "./fileRepairPresentation";
|
||||
|
||||
function createInspection(
|
||||
revision: Partial<FileRepairRevision> = {},
|
||||
storage: { exists: boolean; size?: number; mtime?: number } = {
|
||||
exists: true,
|
||||
size: 12,
|
||||
mtime: 5_500,
|
||||
}
|
||||
): { inspection: FileRepairInspection; revision: FileRepairRevision } {
|
||||
const completeRevision = {
|
||||
role: "conflict",
|
||||
metadata: {
|
||||
documentId: "f:note",
|
||||
revision: "2-conflict",
|
||||
current: false,
|
||||
deleted: false,
|
||||
storageType: "plain",
|
||||
storageLayout: "chunked",
|
||||
ctime: 1,
|
||||
mtime: 2_000,
|
||||
recordedSize: 9,
|
||||
revisionHistory: [],
|
||||
chunkReferences: 0,
|
||||
uniqueChunkReferences: 0,
|
||||
embeddedChunkReferences: 0,
|
||||
locallyStoredChunkReferences: 0,
|
||||
contentAvailableLocally: true,
|
||||
chunks: [],
|
||||
},
|
||||
contentReadable: true,
|
||||
contentMatchesStorage: false,
|
||||
loadedEntry: {
|
||||
_id: "f:note",
|
||||
_rev: "2-conflict",
|
||||
path: "note.md",
|
||||
ctime: 1,
|
||||
mtime: 2_000,
|
||||
size: 9,
|
||||
type: "plain",
|
||||
datatype: "plain",
|
||||
children: [],
|
||||
eden: {},
|
||||
data: "content",
|
||||
},
|
||||
...revision,
|
||||
} as FileRepairRevision;
|
||||
const inspection = {
|
||||
information: {
|
||||
path: "note.md",
|
||||
databasePath: "note.md" as FilePathWithPrefix,
|
||||
storage,
|
||||
database: {
|
||||
source: "local database on this device",
|
||||
remoteQueried: false,
|
||||
exists: true,
|
||||
currentRevision: "3-winner",
|
||||
conflictCount: 1,
|
||||
conflictRevisions: ["2-conflict"],
|
||||
unavailableConflictRevisions: [],
|
||||
revisions: [],
|
||||
mergeBases: [],
|
||||
},
|
||||
},
|
||||
revisions: [completeRevision],
|
||||
requiresAttention: true,
|
||||
} satisfies FileRepairInspection;
|
||||
return { inspection, revision: completeRevision };
|
||||
}
|
||||
|
||||
describe("file repair presentation", () => {
|
||||
it("offers both reconciliation directions for a readable differing revision", () => {
|
||||
const { inspection, revision } = createInspection();
|
||||
|
||||
expect(getFileRepairRevisionActions(inspection, revision)).toEqual({
|
||||
compareWithVault: true,
|
||||
applyRevisionToVault: true,
|
||||
markAsVaultRevision: false,
|
||||
storeVaultOnBranch: true,
|
||||
applyLogicalDeletionToVault: false,
|
||||
retryRevision: false,
|
||||
discardRevision: false,
|
||||
discardBranch: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("marks an exact matching revision without creating another child", () => {
|
||||
const { inspection, revision } = createInspection({
|
||||
contentMatchesStorage: true,
|
||||
});
|
||||
|
||||
expect(getFileRepairRevisionActions(inspection, revision)).toMatchObject({
|
||||
compareWithVault: false,
|
||||
applyRevisionToVault: false,
|
||||
markAsVaultRevision: true,
|
||||
storeVaultOnBranch: false,
|
||||
discardBranch: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not offer a text comparison for a binary file", () => {
|
||||
const { inspection, revision } = createInspection();
|
||||
inspection.information.path = "image.png";
|
||||
|
||||
expect(getFileRepairRevisionActions(inspection, revision)).toMatchObject({
|
||||
compareWithVault: false,
|
||||
applyRevisionToVault: true,
|
||||
storeVaultOnBranch: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("offers explicit deletion or branch extension for a logical deletion", () => {
|
||||
const { inspection, revision } = createInspection({
|
||||
metadata: {
|
||||
...createInspection().revision.metadata,
|
||||
deleted: true,
|
||||
},
|
||||
contentReadable: true,
|
||||
contentMatchesStorage: null,
|
||||
loadedEntry: false,
|
||||
});
|
||||
|
||||
expect(getFileRepairRevisionActions(inspection, revision)).toMatchObject({
|
||||
applyRevisionToVault: false,
|
||||
storeVaultOnBranch: true,
|
||||
applyLogicalDeletionToVault: true,
|
||||
retryRevision: false,
|
||||
discardRevision: false,
|
||||
discardBranch: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("offers retry, discard, and branch extension for an unreadable live revision", () => {
|
||||
const { inspection, revision } = createInspection({
|
||||
contentReadable: false,
|
||||
contentMatchesStorage: null,
|
||||
loadedEntry: false,
|
||||
});
|
||||
|
||||
expect(getFileRepairRevisionActions(inspection, revision)).toMatchObject({
|
||||
compareWithVault: false,
|
||||
applyRevisionToVault: false,
|
||||
markAsVaultRevision: false,
|
||||
storeVaultOnBranch: true,
|
||||
retryRevision: true,
|
||||
discardRevision: false,
|
||||
discardBranch: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the existing unreadable-leaf escape hatch when there is no conflict branch", () => {
|
||||
const { inspection, revision } = createInspection({
|
||||
role: "winner",
|
||||
contentReadable: false,
|
||||
contentMatchesStorage: null,
|
||||
loadedEntry: false,
|
||||
});
|
||||
inspection.information.database.conflictCount = 0;
|
||||
inspection.information.database.conflictRevisions = [];
|
||||
inspection.information.database.currentRevision = revision.metadata.revision;
|
||||
|
||||
expect(getFileRepairRevisionActions(inspection, revision)).toMatchObject({
|
||||
discardRevision: true,
|
||||
discardBranch: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not offer a storage action for a matching absent logical deletion", () => {
|
||||
const { inspection, revision } = createInspection(
|
||||
{
|
||||
metadata: {
|
||||
...createInspection().revision.metadata,
|
||||
deleted: true,
|
||||
},
|
||||
contentReadable: true,
|
||||
contentMatchesStorage: null,
|
||||
loadedEntry: false,
|
||||
},
|
||||
{ exists: false }
|
||||
);
|
||||
|
||||
expect(getFileRepairRevisionActions(inspection, revision)).toMatchObject({
|
||||
applyLogicalDeletionToVault: false,
|
||||
storeVaultOnBranch: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports recorded, decoded, Vault-size, and timestamp differences", () => {
|
||||
const { inspection, revision } = createInspection();
|
||||
|
||||
expect(getFileRepairRevisionComparison(inspection, revision)).toEqual({
|
||||
recordedSize: 9,
|
||||
decodedSize: 7,
|
||||
recordedToDecodedSizeDifference: -2,
|
||||
vaultSize: 12,
|
||||
databaseToVaultSizeDifference: 5,
|
||||
databaseMtime: 2_000,
|
||||
vaultMtime: 5_500,
|
||||
timestampDifferenceMs: 3_500,
|
||||
timestampRelation: "vault-newer",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the same two-second timestamp comparison window as synchronisation", () => {
|
||||
const { inspection, revision } = createInspection(
|
||||
{
|
||||
metadata: {
|
||||
...createInspection().revision.metadata,
|
||||
mtime: 3_001,
|
||||
},
|
||||
},
|
||||
{
|
||||
exists: true,
|
||||
size: 12,
|
||||
mtime: 3_999,
|
||||
}
|
||||
);
|
||||
|
||||
expect(getFileRepairRevisionComparison(inspection, revision)).toMatchObject({
|
||||
timestampDifferenceMs: 998,
|
||||
timestampRelation: "same-window",
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user