Improve recovery diagnostics and actions

This commit is contained in:
vorotamoroz
2026-07-25 09:01:25 +00:00
parent bea0e68091
commit 12fc43a69c
16 changed files with 1980 additions and 243 deletions
+27 -4
View File
@@ -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",
});
});
});