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
@@ -1530,6 +1530,29 @@ Offline Changed files: ${files.length}`;
}
}
private async getLiveInternalRevision(
prefixedFileName: FilePathWithPrefix,
revision: string
): Promise<MetaEntry | false> {
const [selected, current, conflicts] = await Promise.all([
this.core.databaseFileAccess.fetchEntryMeta(prefixedFileName, revision, true),
this.core.databaseFileAccess.fetchEntryMeta(prefixedFileName, undefined, true),
this.core.databaseFileAccess.getConflictedRevs(prefixedFileName),
]);
const liveRevisions = new Set([
...(current && current._rev ? [current._rev] : []),
...conflicts,
]);
if (!selected || selected._rev !== revision || !liveRevisions.has(revision)) {
this._log(
`Could not use hidden-file revision ${revision} of ${stripAllPrefixes(prefixedFileName)}; the selected revision is no longer live`,
LOG_LEVEL_NOTICE
);
return false;
}
return selected;
}
async storeInternalFileToDatabase(file: InternalFileInfo | UXFileInfo, forceWrite = false) {
const storeFilePath = stripAllPrefixes(file.path);
const storageFilePath = file.path;
@@ -1581,6 +1604,79 @@ Offline Changed files: ${files.length}`;
});
}
async storeInternalFileToDatabaseWithBaseRevision(
file: InternalFileInfo | UXFileInfo,
baseRevision: string,
createIfDifferent = true
): Promise<boolean> {
const storeFilePath = stripAllPrefixes(file.path);
const storageFilePath = file.path;
if (await this.services.vault.isIgnoredByIgnoreFile(storageFilePath)) {
return false;
}
const prefixedFileName = addPrefix(storeFilePath, ICHeader);
return await serialized("file-" + prefixedFileName, async () => {
try {
const baseData = await this.getLiveInternalRevision(prefixedFileName, baseRevision);
if (baseData === false) {
return false;
}
const fileInfo = "stat" in file && "body" in file ? file : await this.loadFileWithInfo(storeFilePath);
if (fileInfo.deleted) {
throw new Error(`Hidden file:${storeFilePath} is deleted. This should not be occurred.`);
}
if (!baseData.deleted && !baseData._deleted) {
const loadedBase = await this.core.databaseFileAccess.fetchEntryFromMeta(baseData, true, true);
if (loadedBase && (await isDocContentSame(readAsBlob(loadedBase), fileInfo.body))) {
this.updateLastProcessed(storeFilePath, baseData, fileInfo.stat);
return true;
}
}
if (!createIfDifferent) {
this._log(
`Could not mark hidden file ${storeFilePath} as revision ${baseRevision}; the storage content differs`,
LOG_LEVEL_NOTICE
);
return false;
}
const storedRevision = await this.core.databaseFileAccess.storeWithBaseRevision(
{
...fileInfo,
path: storeFilePath,
name: fileInfo.name || storeFilePath.split("/").pop() || "",
isInternal: true,
},
baseRevision,
true
);
if (storedRevision === false) {
return false;
}
this.updateLastProcessed(
storeFilePath,
{
...baseData,
_rev: storedRevision,
path: prefixedFileName,
ctime: fileInfo.stat.ctime,
mtime: fileInfo.stat.mtime,
size: fileInfo.stat.size,
deleted: false,
},
fileInfo.stat
);
this._log(`STORAGE --> DB:${storageFilePath}: (hidden, selected branch) Done`);
return true;
} catch (ex) {
this._log(`STORAGE --> DB:${storageFilePath}: (hidden, selected branch) Failed`);
this._log(ex, LOG_LEVEL_VERBOSE);
return false;
}
});
}
async deleteInternalFileOnDatabase(filenameSrc: FilePath, forceWrite = false) {
const storeFilePath = filenameSrc;
const storageFilePath = filenameSrc;
@@ -1644,7 +1740,8 @@ Offline Changed files: ${files.length}`;
metaEntry?: MetaEntry | LoadedEntry,
preventDoubleProcess = true,
onlyNew = false,
includeDeletion = true
includeDeletion = true,
requiredLiveRevision?: string
) {
const prefixedFileName = addPrefix(storageFilePath, ICHeader);
if (await this.services.vault.isIgnoredByIgnoreFile(storageFilePath)) {
@@ -1653,9 +1750,11 @@ Offline Changed files: ${files.length}`;
return await serialized("file-" + prefixedFileName, async () => {
try {
// Check conflicted status
const metaOnDB = metaEntry
? metaEntry
: await this.localDatabase.getDBEntryMeta(prefixedFileName, { conflicts: true }, true);
const metaOnDB = requiredLiveRevision
? await this.getLiveInternalRevision(prefixedFileName, requiredLiveRevision)
: metaEntry
? metaEntry
: await this.localDatabase.getDBEntryMeta(prefixedFileName, { conflicts: true }, true);
if (metaOnDB === false) throw new Error(`File not found on database.:${storageFilePath}`);
// Prevent overwrite for Prevent overwriting while some conflicted revision exists.
if (metaOnDB?._conflicts?.length) {
@@ -1729,6 +1828,24 @@ Offline Changed files: ${files.length}`;
});
}
async extractInternalFileRevisionFromDatabase(
storageFilePath: FilePath,
revision: string,
force = false
): Promise<boolean> {
return Boolean(
await this.extractInternalFileFromDatabase(
storageFilePath,
force,
undefined,
true,
false,
true,
revision
)
);
}
async __checkIsNeedToWriteFile(storageFilePath: FilePath, content: string | ArrayBuffer): Promise<boolean> {
try {
const storageContent = await this.core.storageAccess.readHiddenFileAuto(storageFilePath);
@@ -1,5 +1,12 @@
import { describe, expect, it, vi } from "vitest";
import { LOG_LEVEL_NOTICE } from "@vrtmrz/livesync-commonlib/compat/common/types";
import {
type DocumentID,
LOG_LEVEL_NOTICE,
type FilePath,
type FilePathWithPrefix,
type MetaEntry,
type UXFileInfo,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
vi.mock("@/deps.ts", () => ({}));
vi.mock("@/features/HiddenFileCommon/JsonResolveModal.ts", () => ({
@@ -27,6 +34,70 @@ vi.mock("./configureHiddenFileSyncMode.ts", () => ({
import { HiddenFileSync } from "./CmdHiddenFileSync.ts";
import { configureHiddenFileSyncMode } from "./configureHiddenFileSyncMode.ts";
function createHiddenRevisionOperation() {
const path = ".obsidian/plugins/example/data.json" as FilePath;
const file = {
path,
name: "data.json",
isInternal: true,
body: new Blob(["{\"value\":\"vault\"}"]),
stat: {
ctime: 1,
mtime: 2,
size: 17,
type: "file",
},
} as UXFileInfo;
const selected = {
_id: "i:example" as DocumentID,
_rev: "2-selected",
path: `i:${path}` as FilePathWithPrefix,
ctime: 1,
mtime: 2,
size: 17,
type: "plain",
datatype: "plain",
children: [],
eden: {},
deleted: false,
} as MetaEntry;
const winner = {
...selected,
_rev: "3-winner",
} as MetaEntry;
const databaseFileAccess = {
fetchEntryMeta: vi.fn(
async (_path: unknown, revision?: string) =>
revision === selected._rev ? selected : winner
),
getConflictedRevs: vi.fn(async () => [selected._rev]),
fetchEntryFromMeta: vi.fn(async () => ({ ...selected, data: "{\"value\":\"database\"}" })),
storeWithBaseRevision: vi.fn(async () => "3-vault-child"),
};
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
Object.assign(hiddenFileSync, {
core: {
services: {
vault: {
isIgnoredByIgnoreFile: vi.fn(async () => false),
},
},
databaseFileAccess,
},
loadFileWithInfo: vi.fn(async () => file),
updateLastProcessed: vi.fn(),
_log: vi.fn(),
});
return {
hiddenFileSync,
path,
file,
selected,
winner,
databaseFileAccess,
};
}
describe("HiddenFileSync configuration-change notices", () => {
it("shows manual Hidden File Sync commands only when the feature, Advanced mode, and runtime are ready", () => {
const commands: Array<{
@@ -270,3 +341,130 @@ describe("HiddenFileSync configuration-change notices", () => {
expect(progress.done).toHaveBeenCalledWith("Failed");
});
});
describe("HiddenFileSync exact revision repair operations", () => {
it("stores the current hidden Vault file as a child of the selected live revision", async () => {
const {
hiddenFileSync,
file,
selected,
databaseFileAccess,
} = createHiddenRevisionOperation();
await expect(
hiddenFileSync.storeInternalFileToDatabaseWithBaseRevision(file, selected._rev!)
).resolves.toBe(true);
expect(databaseFileAccess.storeWithBaseRevision).toHaveBeenCalledWith(
expect.objectContaining({
path: file.path,
body: file.body,
isInternal: true,
}),
selected._rev,
true
);
expect(hiddenFileSync.updateLastProcessed).toHaveBeenCalledWith(
file.path,
expect.objectContaining({ _rev: "3-vault-child" }),
file.stat
);
});
it("refuses to extend a hidden-file revision which is no longer live", async () => {
const {
hiddenFileSync,
file,
selected,
databaseFileAccess,
} = createHiddenRevisionOperation();
databaseFileAccess.getConflictedRevs.mockResolvedValue([]);
await expect(
hiddenFileSync.storeInternalFileToDatabaseWithBaseRevision(file, selected._rev!)
).resolves.toBe(false);
expect(databaseFileAccess.storeWithBaseRevision).not.toHaveBeenCalled();
expect(hiddenFileSync.updateLastProcessed).not.toHaveBeenCalled();
});
it("does not create a hidden-file child when asked only to mark a revision which differs from the Vault", async () => {
const {
hiddenFileSync,
file,
selected,
databaseFileAccess,
} = createHiddenRevisionOperation();
await expect(
hiddenFileSync.storeInternalFileToDatabaseWithBaseRevision(
file,
selected._rev!,
false
)
).resolves.toBe(false);
expect(databaseFileAccess.storeWithBaseRevision).not.toHaveBeenCalled();
expect(hiddenFileSync.updateLastProcessed).not.toHaveBeenCalled();
});
it("marks a matching hidden-file revision without creating a child", async () => {
const {
hiddenFileSync,
file,
selected,
databaseFileAccess,
} = createHiddenRevisionOperation();
databaseFileAccess.fetchEntryFromMeta.mockResolvedValue({
...selected,
data: "{\"value\":\"vault\"}",
});
await expect(
hiddenFileSync.storeInternalFileToDatabaseWithBaseRevision(
file,
selected._rev!,
false
)
).resolves.toBe(true);
expect(databaseFileAccess.storeWithBaseRevision).not.toHaveBeenCalled();
expect(hiddenFileSync.updateLastProcessed).toHaveBeenCalledWith(
file.path,
selected,
file.stat
);
});
it("applies the selected live hidden-file revision through the existing extraction path", async () => {
const {
hiddenFileSync,
path,
selected,
} = createHiddenRevisionOperation();
const extract = vi.fn(async () => true);
hiddenFileSync.extractInternalFileFromDatabase = extract;
await expect(
hiddenFileSync.extractInternalFileRevisionFromDatabase(path, selected._rev!, true)
).resolves.toBe(true);
expect(extract).toHaveBeenCalledWith(path, true, undefined, true, false, true, selected._rev);
});
it("does not apply a hidden-file revision which ceased to be live", async () => {
const {
hiddenFileSync,
path,
selected,
databaseFileAccess,
} = createHiddenRevisionOperation();
databaseFileAccess.getConflictedRevs.mockResolvedValue([]);
await expect(
hiddenFileSync.extractInternalFileRevisionFromDatabase(path, selected._rev!, true)
).resolves.toBe(false);
expect(databaseFileAccess.fetchEntryFromMeta).not.toHaveBeenCalled();
});
});