diff --git a/docs/design_docs/document_history_revision_restoration.md b/docs/design_docs/document_history_revision_restoration.md new file mode 100644 index 00000000..4fa68263 --- /dev/null +++ b/docs/design_docs/document_history_revision_restoration.md @@ -0,0 +1,192 @@ +# Document History Revision Restoration + +## Status + +Accepted for a limited implementation. + +## Problem and scope + +Document History can reconstruct an available historical revision from its +Chunks and write that content to the Vault through **Back to this revision**. +The current action writes directly through the storage adapter. It does not +create a new Metadata revision, clear a logical deletion through a successor, +or record which database revision produced the restored Vault file. + +This leaves a logically deleted Metadata document at `deleted: true` after the +file has returned to the Vault. A later ordinary Vault save may create a live +successor, but restoration must not depend on an unrelated later file event. + +The same action does not inspect or preserve revision-tree intent explicitly +when conflicts exist. Document History currently displays the ancestry of the +PouchDB winner. It does not display the complete revision tree or the ancestry +of every conflict leaf. + +This design covers restoration of one readable historical revision of a normal +Vault file. It creates a new live revision, reflects that exact revision to the +Vault, and leaves every other live conflict branch available for the existing +conflict workflow. + +## Evidence + +A real-Obsidian exercise created a Markdown document, removed it through the +Vault API, and waited for LiveSync to store a logical-deletion successor. The +Metadata retained all referenced Chunks, and Document History could reconstruct +the deleted content. + +Selecting **Back to this revision** restored the file and its exact bytes to the +Vault. The local database nevertheless retained the same deleted current +revision after file processing had settled. An additional ordinary Vault save +then created a new non-deleted successor. This demonstrates that content +reconstruction works and that the missing operation is the database-aware +restoration step. + +## Revision-tree decision + +The selected historical revision is the content source. It is not necessarily +a live leaf and is not used as the parent of the new write. + +At the time of the restoration operation, LiveSync reads the current PouchDB +winner. The new revision is written as a child of that exact live revision and +contains the selected historical content with no logical-deletion marker. + +For an unconflicted logical deletion: + +```text +A -- D (deleted winner) -- R (restored live successor) +``` + +For an existing conflict: + +```text +A -- W (winner) -- R (restored successor) + \ + C (existing conflict remains live) +``` + +Advancing the winner branch is the ordinary meaning of reverting its content. +The previous winner remains in revision history, while every other live leaf +remains available for conflict resolution. Restoration does not manufacture an +additional independent branch merely to retain the previous winner as another +live conflict. + +The existing **Inspect conflicts and file/database differences** workflow owns +subsequent comparison and resolution of the restored revision and the remaining +live leaves. Document History supplies content which may no longer be a live +leaf; the Inspector operates on the live tree after that content has been +restored. Neither interface replaces the other. + +## Persistence and reflection order + +Restoration performs these steps in order: + +1. read and reconstruct the exact selected historical revision; +2. read the current winner and use its exact revision as the write base; +3. create Chunks and conditionally write a new non-deleted Metadata revision + containing the selected content below that live base; +4. obtain the exact created revision from the database write; +5. reflect that exact revision to the Vault; and +6. record the reflected revision as the device-local file provenance. + +The database write precedes Vault reflection. If the database write fails, the +Vault remains unchanged. If the new revision is stored but Vault reflection +fails, the live restored revision remains recoverable and the operation reports +that the database restoration completed without successful reflection. It does +not remove the new revision in an attempted rollback. + +The new Metadata keeps the current document's creation time, records the +restoration as a new modification, and derives its size and type from the +reconstructed bytes. Reusing the historical modification time would make an +explicit present-day restoration appear older than concurrent changes and +would interact poorly with modification-time policies. + +## Conflicts and concurrent changes + +Existing conflict leaves are never deleted by restoration. When conflicts +remain after the new revision is written, LiveSync keeps the ordinary conflict +indicator and conflict-resolution workflow available. The interface explains +that restoration advances the currently shown branch and does not resolve the +other versions. + +Document History does not perform a separate comparison with the winner which +was current when the dialogue opened. The conditional exact-base write uses an +ordinary PouchDB new edit. If that base is no longer a writable live leaf, +PouchDB rejects the write and the dialogue reports that the revision tree +changed and the operation should be retried. If the base remains live while +another conflict leaf appears, the write may succeed and both branches remain +available. + +Commonlib's existing `storeWithBaseRevision` operation cannot provide this +condition. Its force-write behaviour intentionally uses `new_edits: false` so +that conflict-preservation workflows can create a branch from a supplied +ancestor. The restoration path therefore uses a separate +`storeWithLiveBaseRevision` operation. That operation writes below the supplied +leaf with ordinary PouchDB revision checking and never falls back to the force +path. + +The action must not silently fall back to an unbased write after an exact-base +failure. + +## History presentation boundary + +The current slider continues to represent the available ancestry of the +PouchDB winner. Building a complete branch-aware history viewer would require +loading the ancestry of every live leaf, joining shared ancestors, representing +missing or compacted revisions, and adding an explicit branch-selection +interface. That work is not required to restore the history currently shown. + +When the document has conflicts, the dialogue may state that restoration will +create a new revision on the winner branch which is current when the action +runs, and leave the other versions unresolved. Detailed branch comparison +remains in the Inspector. + +## Ownership + +LiveSync owns: + +- selection and reconstruction of the historical revision; +- the Document History user interaction and result messages; +- orchestration of the exact-base write and exact-revision reflection; and +- presentation of any remaining conflict state. + +Commonlib owns: + +- Chunk creation and Metadata persistence; +- `storeWithLiveBaseRevision`, which writes content as a normal child of an + exact live revision and returns the created revision; +- rejecting an unavailable or stale base revision; +- reflecting an exact live revision to storage; and +- recording device-local file-reflection provenance. + +The implementation retains `storeWithBaseRevision` for the conflict workflows +which deliberately create branches. The new conditional operation is narrower +and is not a replacement for that existing behaviour. + +## Non-goals + +This change does not: + +- identify the origin of malformed or doubled Metadata paths; +- turn Document History into a complete revision-tree viewer; +- select, merge, or discard existing conflict leaves; +- restore unavailable content whose Chunks cannot be reconstructed; +- mutate an old revision or clear its deletion marker in place; +- rebuild a local or remote database; or +- change automatic conflict-resolution policy. + +## Verification + +Focused tests cover: + +- restoring readable content as a non-deleted child of a deleted winner; +- returning and reflecting the exact created revision; +- retaining every existing conflict leaf while advancing the winner branch; +- refusing an exact-base write when the base is no longer live; +- retaining the existing force-write behaviour for callers which deliberately + create a conflict branch; +- leaving the Vault unchanged when database persistence fails; and +- retaining the stored live revision when subsequent Vault reflection fails. + +The real-Obsidian regression exercise removes the additional normal Vault save +from the earlier reproduction. **Back to this revision** must itself produce a +new live database revision, restore the exact content to the Vault, and reopen +Document History at that successor. diff --git a/package.json b/package.json index 732ab0de..10983105 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "test:e2e:obsidian:conflict-dialog-policy": "tsx test/e2e-obsidian/scripts/conflict-dialog-policy.ts", "test:e2e:obsidian:revision-repair": "tsx test/e2e-obsidian/scripts/revision-repair.ts", "test:e2e:obsidian:document-history-nav": "tsx test/e2e-obsidian/scripts/document-history-nav.ts", + "test:e2e:obsidian:document-history-restore": "tsx test/e2e-obsidian/scripts/document-history-restore.ts", "test:e2e:obsidian:settings-ui": "tsx test/e2e-obsidian/scripts/settings-ui.ts", "test:e2e:obsidian:review-harness": "tsx test/e2e-obsidian/scripts/review-harness.ts", "test:e2e:obsidian:p2p-pane": "tsx test/e2e-obsidian/scripts/p2p-pane.ts", diff --git a/src/modules/features/DocumentHistory/DocumentHistoryModal.ts b/src/modules/features/DocumentHistory/DocumentHistoryModal.ts index 3a173ebd..57b734ba 100644 --- a/src/modules/features/DocumentHistory/DocumentHistoryModal.ts +++ b/src/modules/features/DocumentHistory/DocumentHistoryModal.ts @@ -12,7 +12,7 @@ import { } from "@vrtmrz/livesync-commonlib/compat/common/types"; import { Logger } from "@vrtmrz/livesync-commonlib/compat/common/logger"; import { isErrorOfMissingDoc } from "@vrtmrz/livesync-commonlib/compat/pouchdb/utils_couchdb"; -import { fireAndForget, getDocData, readContent } from "@vrtmrz/livesync-commonlib/compat/common/utils"; +import { fireAndForget, getDocData } from "@vrtmrz/livesync-commonlib/compat/common/utils"; import { isPlainText, stripPrefix } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path"; import { scheduleOnceIfDuplicated } from "octagonal-wheels/concurrency/lock"; import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore.ts"; @@ -23,6 +23,10 @@ import { saveDocumentHistoryPreference, } from "./documentHistoryPreferences.ts"; import type PouchDB from "pouchdb-core"; +import { + restoreDocumentHistoryRevision, + type DocumentHistoryRestorationResult, +} from "@/serviceFeatures/documentHistoryRestoration"; function isImage(path: string) { const ext = path.split(".").splice(-1)[0].toLowerCase(); @@ -277,6 +281,7 @@ export class DocumentHistoryModal extends Modal { async showExactRev(rev: string) { const db = this.core.localDatabase; + this.currentDoc = undefined; const w = await db.getDBEntry(this.file, { rev: rev }, false, false, true); this.currentText = ""; this.currentDeleted = false; @@ -702,7 +707,6 @@ export class DocumentHistoryModal extends Modal { e.addClass("mod-cta"); e.addEventListener("click", () => { fireAndForget(async () => { - // const pathToWrite = this.plugin.id2path(this.id, true); const pathToWrite = stripPrefix(this.file); if (!isValidPath(pathToWrite)) { Logger("Path is not valid to write content.", LOG_LEVEL_INFO); @@ -712,9 +716,94 @@ export class DocumentHistoryModal extends Modal { Logger("No active file loaded.", LOG_LEVEL_INFO); return; } - const d = readContent(this.currentDoc); - await this.core.storageAccess.writeHiddenFileAuto(pathToWrite, d); - await focusFile(pathToWrite); + const sourceRevision = this.currentDoc._rev; + if (!sourceRevision) { + Logger("The selected revision does not have a revision identifier.", LOG_LEVEL_NOTICE); + return; + } + + e.disabled = true; + let result: DocumentHistoryRestorationResult; + try { + result = await restoreDocumentHistoryRevision(this.core, this.file, sourceRevision, { + isPathValid: isValidPath, + }); + } catch (ex) { + Logger( + "Restoring the selected revision failed before Vault reflection completed. Review the file with 'Inspect conflicts and file/database differences' before retrying.", + LOG_LEVEL_NOTICE + ); + Logger(ex, LOG_LEVEL_VERBOSE); + e.disabled = false; + return; + } + + if (result.status === "source-unavailable") { + Logger( + "The selected revision could not be restored because its content is no longer available.", + LOG_LEVEL_NOTICE + ); + e.disabled = false; + return; + } + if (result.status === "current-unavailable") { + Logger( + "The current database revision could not be read. The Vault was not changed.", + LOG_LEVEL_NOTICE + ); + e.disabled = false; + return; + } + if (result.status === "unsupported-path") { + Logger( + "Only an ordinary valid Vault path can be restored from Document History.", + LOG_LEVEL_NOTICE + ); + e.disabled = false; + return; + } + if (result.status === "database-write-refused") { + Logger( + "The restored revision could not be created. The file may have changed during the operation, and the Vault was not changed.", + LOG_LEVEL_NOTICE + ); + e.disabled = false; + return; + } + if (result.status === "stored-not-reflected") { + Logger( + "The restored revision was saved in the local database, but it could not be reflected to the Vault. Use 'Inspect conflicts and file/database differences' to review and apply it.", + LOG_LEVEL_NOTICE + ); + if (result.cause) { + Logger(result.cause, LOG_LEVEL_VERBOSE); + } + this.close(); + return; + } + + if (result.conflictCheckError) { + Logger(result.conflictCheckError, LOG_LEVEL_VERBOSE); + } + if (result.conflictsRemain === true) { + Logger( + "The selected content was restored as a new revision. Other versions remain unresolved; use 'Inspect conflicts and file/database differences' to review them.", + LOG_LEVEL_NOTICE + ); + } else if (result.conflictsRemain === false) { + Logger("The selected content was restored as a new revision.", LOG_LEVEL_NOTICE); + } else { + Logger( + "The selected content was restored as a new revision. LiveSync could not confirm whether other versions remain; use 'Inspect conflicts and file/database differences' to review the file.", + LOG_LEVEL_NOTICE + ); + } + try { + await focusFile(result.path); + } catch (ex) { + Logger("The restored file could not be opened in the editor.", LOG_LEVEL_NOTICE); + Logger(ex, LOG_LEVEL_VERBOSE); + } this.close(); }); }); diff --git a/src/serviceFeatures/documentHistoryRestoration.ts b/src/serviceFeatures/documentHistoryRestoration.ts new file mode 100644 index 00000000..456653c5 --- /dev/null +++ b/src/serviceFeatures/documentHistoryRestoration.ts @@ -0,0 +1,98 @@ +import type { FilePath, FilePathWithPrefix, UXFileInfo } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import { readAsBlob } from "@vrtmrz/livesync-commonlib/compat/common/utils"; +import type { DatabaseFileAccess } from "@vrtmrz/livesync-commonlib/compat/interfaces/DatabaseFileAccess"; +import type { IFileHandler } from "@vrtmrz/livesync-commonlib/compat/interfaces/FileHandler"; +import { stripAllPrefixes } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path"; + +export type DocumentHistoryRestorationCore = { + databaseFileAccess: Pick< + DatabaseFileAccess, + "fetchEntry" | "fetchEntryMeta" | "getConflictedRevs" | "storeWithLiveBaseRevision" + >; + fileHandler: Pick; +}; + +export type DocumentHistoryRestorationResult = + | { + status: "restored"; + path: FilePath; + revision: string; + conflictsRemain: boolean | undefined; + conflictCheckError?: unknown; + } + | { status: "source-unavailable" } + | { status: "current-unavailable" } + | { status: "unsupported-path" } + | { status: "database-write-refused" } + | { status: "stored-not-reflected"; path: FilePath; revision: string; cause?: unknown }; + +export type DocumentHistoryRestorationOptions = { + now?: () => number; + isPathValid?: (path: FilePath) => boolean; +}; + +/** + * Restore historical content as a new child of the current live database winner, then reflect + * that exact new revision to the Vault. The historical revision supplies content, not ancestry. + */ +export async function restoreDocumentHistoryRevision( + core: DocumentHistoryRestorationCore, + path: FilePathWithPrefix, + sourceRevision: string, + options: DocumentHistoryRestorationOptions = {} +): Promise { + const now = options.now ?? Date.now; + const isPathValid = options.isPathValid ?? (() => true); + const source = await core.databaseFileAccess.fetchEntry(path, sourceRevision, true, true); + if (source === false) { + return { status: "source-unavailable" }; + } + + const current = await core.databaseFileAccess.fetchEntryMeta(path, undefined, true); + if (current === false || !current._rev) { + return { status: "current-unavailable" }; + } + + const body = readAsBlob(source); + const storagePath = stripAllPrefixes(current.path); + if (storagePath !== current.path || !isPathValid(storagePath)) { + return { status: "unsupported-path" }; + } + const file: UXFileInfo = { + name: storagePath.split("/").pop() ?? storagePath, + path: storagePath, + stat: { + ctime: current.ctime, + mtime: now(), + size: body.size, + type: "file", + }, + body, + }; + const revision = await core.databaseFileAccess.storeWithLiveBaseRevision(file, current._rev, true); + if (revision === false) { + return { status: "database-write-refused" }; + } + + try { + const reflected = await core.fileHandler.dbToStorageWithSpecificRev(storagePath, revision, true); + if (!reflected) { + return { status: "stored-not-reflected", path: storagePath, revision }; + } + } catch (cause) { + return { status: "stored-not-reflected", path: storagePath, revision, cause }; + } + + try { + const conflictsRemain = (await core.databaseFileAccess.getConflictedRevs(storagePath)).length > 0; + return { status: "restored", path: storagePath, revision, conflictsRemain }; + } catch (conflictCheckError) { + return { + status: "restored", + path: storagePath, + revision, + conflictsRemain: undefined, + conflictCheckError, + }; + } +} diff --git a/src/serviceFeatures/documentHistoryRestoration.unit.spec.ts b/src/serviceFeatures/documentHistoryRestoration.unit.spec.ts new file mode 100644 index 00000000..bac965d3 --- /dev/null +++ b/src/serviceFeatures/documentHistoryRestoration.unit.spec.ts @@ -0,0 +1,246 @@ +import { describe, expect, it, vi } from "vitest"; +import type { + FilePath, + FilePathWithPrefix, + LoadedEntry, + MetaEntry, + UXFileInfo, +} from "@vrtmrz/livesync-commonlib/compat/common/types"; +import { restoreDocumentHistoryRevision, type DocumentHistoryRestorationCore } from "./documentHistoryRestoration"; + +const path = "history.md" as FilePathWithPrefix; +const currentPath = "History.md" as FilePathWithPrefix; + +function createSource(): LoadedEntry { + return { + _id: "f:history", + _rev: "2-source", + path, + ctime: 10, + mtime: 20, + size: 18, + type: "plain", + datatype: "plain", + children: ["h:source"], + data: ["historical content"], + eden: {}, + } as LoadedEntry; +} + +function createCurrent(): MetaEntry { + return { + _id: "f:history", + _rev: "4-deleted", + path: currentPath, + ctime: 10, + mtime: 40, + size: 18, + type: "plain", + children: ["h:current"], + deleted: true, + eden: {}, + } as MetaEntry; +} + +function createCore( + overrides: { + source?: LoadedEntry | false; + current?: MetaEntry | false; + storedRevision?: string | false; + reflected?: boolean; + reflectionError?: unknown; + conflicts?: string[]; + conflictCheckError?: unknown; + } = {} +) { + const calls: string[] = []; + const fetchEntry = vi.fn(async () => { + calls.push("read-source"); + return overrides.source === undefined ? createSource() : overrides.source; + }); + const fetchEntryMeta = vi.fn(async () => { + calls.push("read-current"); + return overrides.current === undefined ? createCurrent() : overrides.current; + }); + const storeWithLiveBaseRevision = vi.fn(async (_file: UXFileInfo) => { + calls.push("store"); + return overrides.storedRevision === undefined ? "5-restored" : overrides.storedRevision; + }); + const getConflictedRevs = vi.fn(async () => { + calls.push("check-conflicts"); + if (overrides.conflictCheckError !== undefined) { + throw overrides.conflictCheckError; + } + return overrides.conflicts ?? []; + }); + const dbToStorageWithSpecificRev = vi.fn(async () => { + calls.push("reflect"); + if (overrides.reflectionError !== undefined) { + throw overrides.reflectionError; + } + return overrides.reflected ?? true; + }); + const core: DocumentHistoryRestorationCore = { + databaseFileAccess: { + fetchEntry, + fetchEntryMeta, + getConflictedRevs, + storeWithLiveBaseRevision, + }, + fileHandler: { + dbToStorageWithSpecificRev, + }, + }; + return { + calls, + core, + dbToStorageWithSpecificRev, + fetchEntry, + fetchEntryMeta, + getConflictedRevs, + storeWithLiveBaseRevision, + }; +} + +describe("restoreDocumentHistoryRevision", () => { + it("stores historical bytes below the current deleted winner before reflecting the exact new revision", async () => { + const { calls, core, dbToStorageWithSpecificRev, fetchEntry, fetchEntryMeta, storeWithLiveBaseRevision } = + createCore(); + + await expect(restoreDocumentHistoryRevision(core, path, "2-source", { now: () => 50 })).resolves.toEqual({ + status: "restored", + path: "History.md" as FilePath, + revision: "5-restored", + conflictsRemain: false, + }); + + expect(calls).toEqual(["read-source", "read-current", "store", "reflect", "check-conflicts"]); + expect(fetchEntry).toHaveBeenCalledWith(path, "2-source", true, true); + expect(fetchEntryMeta).toHaveBeenCalledWith(path, undefined, true); + expect(storeWithLiveBaseRevision).toHaveBeenCalledWith( + expect.objectContaining({ + name: "History.md", + path: "History.md", + stat: { + ctime: 10, + mtime: 50, + size: 18, + type: "file", + }, + body: expect.any(Blob), + }), + "4-deleted", + true + ); + const storedFile = storeWithLiveBaseRevision.mock.calls[0][0]; + await expect(storedFile.body.text()).resolves.toBe("historical content"); + expect(storedFile.deleted).toBeUndefined(); + expect(dbToStorageWithSpecificRev).toHaveBeenCalledWith("History.md", "5-restored", true); + }); + + it("leaves the Vault unchanged when the conditional database write is refused", async () => { + const { calls, core, dbToStorageWithSpecificRev } = createCore({ storedRevision: false }); + + await expect(restoreDocumentHistoryRevision(core, path, "2-source")).resolves.toEqual({ + status: "database-write-refused", + }); + + expect(calls).toEqual(["read-source", "read-current", "store"]); + expect(dbToStorageWithSpecificRev).not.toHaveBeenCalled(); + }); + + it("reports a stored revision when its subsequent Vault reflection fails", async () => { + const { core, storeWithLiveBaseRevision } = createCore({ reflected: false }); + + await expect(restoreDocumentHistoryRevision(core, path, "2-source")).resolves.toEqual({ + status: "stored-not-reflected", + path: "History.md" as FilePath, + revision: "5-restored", + }); + + expect(storeWithLiveBaseRevision).toHaveBeenCalledOnce(); + }); + + it("reports a stored revision when its subsequent Vault reflection throws", async () => { + const reflectionError = new Error("adapter unavailable"); + const { core, getConflictedRevs } = createCore({ reflectionError }); + + await expect(restoreDocumentHistoryRevision(core, path, "2-source")).resolves.toEqual({ + status: "stored-not-reflected", + path: "History.md" as FilePath, + revision: "5-restored", + cause: reflectionError, + }); + + expect(getConflictedRevs).not.toHaveBeenCalled(); + }); + + it("reports remaining live conflict leaves after restoration", async () => { + const { core, getConflictedRevs } = createCore({ conflicts: ["3-other"] }); + + await expect(restoreDocumentHistoryRevision(core, path, "2-source")).resolves.toEqual({ + status: "restored", + path: "History.md" as FilePath, + revision: "5-restored", + conflictsRemain: true, + }); + + expect(getConflictedRevs).toHaveBeenCalledWith("History.md"); + }); + + it("does not turn a completed restoration into a failure when conflict inspection fails", async () => { + const conflictCheckError = new Error("inspection unavailable"); + const { core } = createCore({ conflictCheckError }); + + await expect(restoreDocumentHistoryRevision(core, path, "2-source")).resolves.toEqual({ + status: "restored", + path: "History.md" as FilePath, + revision: "5-restored", + conflictsRemain: undefined, + conflictCheckError, + }); + }); + + it("does not read or mutate the current tree when the historical source is unavailable", async () => { + const { core, fetchEntryMeta, storeWithLiveBaseRevision } = createCore({ source: false }); + + await expect(restoreDocumentHistoryRevision(core, path, "2-source")).resolves.toEqual({ + status: "source-unavailable", + }); + + expect(fetchEntryMeta).not.toHaveBeenCalled(); + expect(storeWithLiveBaseRevision).not.toHaveBeenCalled(); + }); + + it("does not write when the current winner cannot supply an exact base revision", async () => { + const current = { ...createCurrent(), _rev: undefined } as MetaEntry; + const { core, storeWithLiveBaseRevision } = createCore({ current }); + + await expect(restoreDocumentHistoryRevision(core, path, "2-source")).resolves.toEqual({ + status: "current-unavailable", + }); + + expect(storeWithLiveBaseRevision).not.toHaveBeenCalled(); + }); + + it("refuses prefixed internal Metadata before creating a database revision", async () => { + const current = { ...createCurrent(), path: "i:.obsidian/config.json" as FilePathWithPrefix } as MetaEntry; + const { core, storeWithLiveBaseRevision } = createCore({ current }); + + await expect(restoreDocumentHistoryRevision(core, path, "2-source")).resolves.toEqual({ + status: "unsupported-path", + }); + + expect(storeWithLiveBaseRevision).not.toHaveBeenCalled(); + }); + + it("uses the host path validator before creating a database revision", async () => { + const { core, storeWithLiveBaseRevision } = createCore(); + + await expect( + restoreDocumentHistoryRevision(core, path, "2-source", { isPathValid: () => false }) + ).resolves.toEqual({ status: "unsupported-path" }); + + expect(storeWithLiveBaseRevision).not.toHaveBeenCalled(); + }); +}); diff --git a/test/e2e-obsidian/README.md b/test/e2e-obsidian/README.md index 864da52f..6efbd70d 100644 --- a/test/e2e-obsidian/README.md +++ b/test/e2e-obsidian/README.md @@ -169,6 +169,8 @@ This proves in real Obsidian the plug-in behaviour shared by supported platforms `test:e2e:obsidian:revision-repair` creates an ordinary healthy logical deletion and two conflicting live revisions in a temporary real Obsidian Vault, then removes a chunk used only by the non-winning revision. It proves that automatic conflict checking does not discard the unreadable branch, and that a healthy logical deletion with no Vault file is neither reported nor retained as Vault provenance. **Inspect conflicts and file/database differences** must show the winner and conflict separately, identify the exact unreadable revision and missing chunk, show the compact `Δsize` and `Δtime` diagnostics, and expose a wrench menu with the appropriate actions for each branch. The scenario opens the existing comparison dialogue in read-only mode, applies the readable winner to the Vault, shows the compact matching-winner and remaining-conflict status, records the exact winner as Vault provenance without creating a child, and confirms that retrying the unreadable branch leaves the revision tree unchanged. It then verifies both the cancellation path and the explicit confirmation path for discarding only that selected live branch, requires the winner and its Vault provenance to remain unchanged, and captures the repair card, a 360-pixel-wide reflow check, the matching-winner status, both revision menus, and the read-only comparison. The narrow capture checks responsive layout, not a mobile operating-system lifecycle. The scenario uses no remote service; a retry is therefore expected to remain unreadable unless the chunk is already available locally. +`test:e2e:obsidian:document-history-restore` creates a normal note, records a logical deletion while retaining readable chunks, and restores the deleted content through the visible Document History dialogue. It requires the action itself to create and reflect a new live successor revision, reopens the history at that successor, and captures the file picker, readable deleted revision, restored Vault file, and new live revision. This scenario owns the ordinary-history restoration boundary; conflict resolution remains with **Inspect conflicts and file/database differences**. + `test:e2e:obsidian:hidden-file-snippet-sync` runs a two-vault hidden file round-trip. It verifies creation and deletion of a real `.obsidian/snippets/*.css` file, automatic JSON conflict merging for a hidden file with the merged result propagated by a second synchronisation, manual JSON Resolve dialogue application through Obsidian's UI, and per-device target patterns where one vault ignores a hidden file that the other vault synchronises. Initial enablement must open one user-visible progress Notice before the enabled setting is saved, then retain that Notice while its nested rebuild and scan phases continue in the ordinary log. The configured fixture starts with a current CouchDB remote profile, so migration from legacy remote settings remains the responsibility of the upgrade scenarios and cannot add unrelated Notices to this check. It also covers [issue #555](https://github.com/vrtmrz/obsidian-livesync/issues/555) by requiring several plug-in and settings changes to share one separate action Notice whose controls remain usable in mobile layouts; a manually dismissed group must not repeat its acknowledged rows when a later change arrives. `test:e2e:obsidian:customisation-sync` runs a two-vault Customisation Sync workflow. It scans a real snippet CSS file, config JSON file, and sample plug-in fixture into per-file Customisation Sync data, synchronises the entries through CouchDB, applies them on the second vault, verifies the resulting `.obsidian` files, propagates a snippet update, and verifies deletion of the source-vault snippet sync data without confusing it with the target vault's own applied copy. diff --git a/test/e2e-obsidian/scripts/document-history-restore.ts b/test/e2e-obsidian/scripts/document-history-restore.ts new file mode 100644 index 00000000..802c59f3 --- /dev/null +++ b/test/e2e-obsidian/scripts/document-history-restore.ts @@ -0,0 +1,376 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import type { Page } from "playwright"; +import { evalObsidianJson } from "../runner/cli.ts"; +import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts"; +import { + createE2eObsidianDeviceLocalState, + waitForLiveSyncCoreReady, + waitForLocalDatabaseEntry, +} from "../runner/liveSyncWorkflow.ts"; +import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts"; +import { withObsidianPage } from "../runner/ui.ts"; +import { createTemporaryVault } from "../runner/vault.ts"; + +process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "60000"; +process.env.E2E_OBSIDIAN_CORE_READY_TIMEOUT_MS ??= "60000"; +process.env.E2E_OBSIDIAN_LOCAL_DB_TIMEOUT_MS ??= "30000"; + +const notePath = "E2E/document-history-soft-deleted.md"; +const contentMarker = "Recoverable content from the soft-deleted revision"; +const noteContent = [ + "# Document History recovery E2E", + "", + contentMarker, + "", + ...Array.from( + { length: 96 }, + (_, index) => `Preserved content line ${String(index + 1).padStart(3, "0")}: ${"R".repeat(64)}` + ), + "", +].join("\n"); + +type SoftDeletionState = { + id: string; + revision: string; + revisionCount: number; + chunkReferences: number; + availableChunks: number; + contentReadable: boolean; + storageExists: boolean; +}; + +type VaultRestoreState = { + revision: string; + revisionCount: number; + deleted: boolean; + storageExists: boolean; + contentMatches: boolean; +}; + +function assertEqual(actual: unknown, expected: unknown, message: string): void { + if (actual !== expected) { + throw new Error(`${message}\nExpected: ${String(expected)}\nActual: ${String(actual)}`); + } +} + +function assertTrue(value: boolean, message: string): void { + if (!value) { + throw new Error(message); + } +} + +async function dismissWelcomeWizard(port: number): Promise { + await withObsidianPage(port, async (page) => { + const cancel = page.getByText("No, please take me back"); + if (await cancel.isVisible({ timeout: 5000 }).catch(() => false)) { + await cancel.click(); + await page.waitForTimeout(500); + } + }); +} + +async function createNote(cliBinary: string, env: NodeJS.ProcessEnv): Promise { + await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + `const path=${JSON.stringify(notePath)};`, + `const content=${JSON.stringify(noteContent)};`, + "if(!(await app.vault.adapter.exists('E2E'))) await app.vault.createFolder('E2E');", + "const existing=app.vault.getAbstractFileByPath(path);", + "if(existing) await app.vault.delete(existing);", + "const file=await app.vault.create(path,content);", + "await app.workspace.getLeaf(false).openFile(file);", + "return JSON.stringify({ok:true});", + "})()", + ].join(""), + env + ); + await waitForLocalDatabaseEntry(cliBinary, env, notePath); +} + +async function createSoftDeletion(cliBinary: string, env: NodeJS.ProcessEnv): Promise { + return await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + `const path=${JSON.stringify(notePath)};`, + `const expectedContent=${JSON.stringify(noteContent)};`, + "const timeoutMs=30000;", + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "const file=app.vault.getAbstractFileByPath(path);", + "if(!file) throw new Error(`Recovery fixture is missing from the Vault: ${path}`);", + "const id=await core.services.path.path2id(path);", + "await app.vault.delete(file);", + "const deadline=Date.now()+timeoutMs;", + "const sleep=(ms)=>new Promise((resolve)=>setTimeout(resolve,ms));", + "while(Date.now()false);", + " if(raw?.deleted&&!app.vault.getAbstractFileByPath(path)){", + " const loaded=await core.localDatabase.getDBEntry(path,{rev:raw._rev},false,true,true);", + " const loadedContent=loaded===false?'':Array.isArray(loaded.data)?loaded.data.join(''):loaded.data;", + " const children=Array.isArray(raw.children)?raw.children:[];", + " const chunkRows=children.length===0?{rows:[]}:await core.localDatabase.allDocsRaw({keys:children,include_docs:true});", + " const availableChunks=chunkRows.rows.filter((row)=>row.doc&&!row.value?.deleted).length;", + " return JSON.stringify({", + " id,", + " revision:raw._rev,", + " revisionCount:(raw._revs_info||[]).filter((entry)=>entry?.status==='available').length,", + " chunkReferences:children.length,", + " availableChunks,", + " contentReadable:loadedContent===expectedContent,", + " storageExists:!!app.vault.getAbstractFileByPath(path),", + " });", + " }", + " await sleep(250);", + "}", + "throw new Error(`Timed out waiting for a readable soft deletion: ${path}`);", + "})()", + ].join(""), + env + ); +} + +async function openHistoryPicker(cliBinary: string, env: NodeJS.ProcessEnv): Promise { + await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + "document.querySelectorAll('.modal-close-button').forEach((button)=>button.click());", + "await new Promise((resolve)=>setTimeout(resolve,300));", + "await app.commands.executeCommandById('obsidian-livesync:livesync-filehistory');", + "await new Promise((resolve)=>setTimeout(resolve,500));", + "return JSON.stringify({opened:!!document.querySelector('.prompt-input')});", + "})()", + ].join(""), + env + ); +} + +async function waitForVaultRestore(cliBinary: string, env: NodeJS.ProcessEnv): Promise { + return await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + `const path=${JSON.stringify(notePath)};`, + `const expectedContent=${JSON.stringify(noteContent)};`, + "const timeoutMs=30000;", + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "const id=await core.services.path.path2id(path);", + "const deadline=Date.now()+timeoutMs;", + "const sleep=(ms)=>new Promise((resolve)=>setTimeout(resolve,ms));", + "while(Date.now()false);", + " const content=file?await app.vault.read(file):'';", + " if(file&&raw&&!raw.deleted&&!raw._deleted&&content===expectedContent){", + " return JSON.stringify({", + " revision:raw._rev,", + " revisionCount:(raw._revs_info||[]).filter((entry)=>entry?.status==='available').length,", + " deleted:false,", + " storageExists:true,", + " contentMatches:true,", + " });", + " }", + " await sleep(250);", + "}", + "const file=app.vault.getAbstractFileByPath(path);", + "const raw=await core.localDatabase.getRaw(id,{revs_info:true}).catch(()=>false);", + "const content=file?await app.vault.read(file):'';", + "throw new Error(`Timed out waiting for History to create and reflect a live successor revision: ${JSON.stringify({storageExists:!!file,deleted:!!(raw&&((raw.deleted||raw._deleted))),contentMatches:content===expectedContent,revision:raw&&raw._rev})}`);", + "})()", + ].join(""), + env + ); +} + +async function openActiveFileHistory(cliBinary: string, env: NodeJS.ProcessEnv): Promise { + await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + `const path=${JSON.stringify(notePath)};`, + "document.querySelectorAll('.modal-close-button').forEach((button)=>button.click());", + "const file=app.vault.getAbstractFileByPath(path);", + "if(!file) throw new Error(`Restored file is missing before reopening history: ${path}`);", + "await app.workspace.getLeaf(false).openFile(file);", + "await new Promise((resolve)=>setTimeout(resolve,300));", + "await app.commands.executeCommandById('obsidian-livesync:livesync-history');", + "await new Promise((resolve)=>setTimeout(resolve,500));", + "return JSON.stringify({opened:!!document.querySelector('.modal-container .modal-title')});", + "})()", + ].join(""), + env + ); +} + +async function captureStep(page: Page, screenshotDir: string, step: string): Promise { + await mkdir(screenshotDir, { recursive: true }); + const path = join(screenshotDir, `${step}.png`); + await page.screenshot({ path, fullPage: true, animations: "disabled" }); + console.log(`Screenshot: ${path}`); + return path; +} + +async function main(): Promise { + const binary = requireObsidianBinary(); + const cli = discoverObsidianCli(); + if (!cli.binary) throw new Error(`Could not find obsidian-cli. Checked paths: ${cli.checked.join(", ")}`); + + const vault = await createTemporaryVault(); + let session: ObsidianLiveSyncSession | undefined; + const screenshotDir = + process.env.E2E_OBSIDIAN_HISTORY_RESTORE_SCREENSHOT_DIR ?? + join(process.env.E2E_OBSIDIAN_DIAGNOSTICS_DIR ?? "/tmp/obsidian-livesync-e2e", "document-history-restore"); + const reportPath = + process.env.E2E_OBSIDIAN_HISTORY_RESTORE_REPORT ?? join(screenshotDir, "document-history-restore.json"); + + try { + console.log(`Using Obsidian executable: ${binary}`); + console.log(`Temporary vault: ${vault.path}`); + + session = await startObsidianLiveSyncSession({ + binary, + cliBinary: cli.binary, + vault, + startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000), + pluginData: { + doctorProcessedVersion: "1.0.0", + isConfigured: true, + liveSync: false, + remoteType: "", + couchDB_URI: "", + couchDB_DBNAME: "", + couchDB_USER: "", + couchDB_PASSWORD: "", + remoteConfigurations: {}, + activeConfigurationId: "", + notifyThresholdOfRemoteStorageSize: -1, + periodicReplication: false, + syncAfterMerge: false, + syncOnEditorSave: false, + syncOnFileOpen: false, + syncOnSave: false, + syncOnStart: false, + deleteMetadataOfDeletedFiles: false, + }, + localStorageEntries: createE2eObsidianDeviceLocalState(vault.name), + }); + await waitForLiveSyncCoreReady(cli.binary, session.cliEnv); + await dismissWelcomeWizard(session.remoteDebuggingPort); + + await createNote(cli.binary, session.cliEnv); + const deletion = await createSoftDeletion(cli.binary, session.cliEnv); + assertEqual(deletion.storageExists, false, "The deletion fixture still existed in the Vault."); + assertTrue(deletion.chunkReferences > 0, "The deleted document did not retain chunk references."); + assertEqual( + deletion.availableChunks, + deletion.chunkReferences, + "Not all chunks referenced by the deleted document remained available." + ); + assertEqual( + deletion.contentReadable, + true, + "The soft-deleted revision could not be reconstructed from chunks." + ); + + await openHistoryPicker(cli.binary, session.cliEnv); + + const screenshots = await withObsidianPage(session.remoteDebuggingPort, async (page) => { + const screenshotPaths: string[] = []; + const prompt = page.locator(".prompt"); + await prompt.waitFor({ state: "visible", timeout: 10000 }); + const promptInput = prompt.locator(".prompt-input"); + assertEqual( + await promptInput.getAttribute("placeholder"), + "File to view History", + "Unexpected history picker placeholder." + ); + await promptInput.fill(notePath); + const suggestion = prompt.locator(".suggestion-item").filter({ hasText: notePath }).first(); + await suggestion.waitFor({ state: "visible", timeout: 10000 }); + screenshotPaths.push(await captureStep(page, screenshotDir, "01-soft-deleted-file-picker")); + + await suggestion.click(); + const modal = page.locator(".modal-container").filter({ hasText: "Document History" }); + await modal.waitFor({ state: "visible", timeout: 10000 }); + await modal.getByText("(At this revision, the file has been deleted)", { exact: false }).waitFor({ + state: "visible", + timeout: 10000, + }); + await modal.getByText(contentMarker, { exact: false }).waitFor({ state: "visible", timeout: 10000 }); + const restoreButton = modal.getByRole("button", { name: "Back to this revision", exact: true }); + await restoreButton.waitFor({ state: "visible", timeout: 10000 }); + screenshotPaths.push(await captureStep(page, screenshotDir, "02-readable-deleted-revision")); + + await restoreButton.click(); + await modal.waitFor({ state: "hidden", timeout: 10000 }); + return screenshotPaths; + }); + + const restored = await waitForVaultRestore(cli.binary, session.cliEnv); + assertEqual(restored.storageExists, true, "Document History did not restore the Vault file."); + assertEqual(restored.contentMatches, true, "The restored Vault file did not match the deleted revision."); + assertEqual(restored.deleted, false, "Document History did not produce a live database revision."); + assertTrue( + restored.revision !== deletion.revision, + "Document History did not create a successor database revision." + ); + assertEqual( + restored.revisionCount, + deletion.revisionCount + 1, + "Document History did not add exactly one live successor revision." + ); + + screenshots.push( + await withObsidianPage(session.remoteDebuggingPort, async (page) => { + await page + .getByText(contentMarker, { exact: false }) + .first() + .waitFor({ state: "visible", timeout: 10000 }); + return await captureStep(page, screenshotDir, "03-live-successor-after-history-restore"); + }) + ); + + await openActiveFileHistory(cli.binary, session.cliEnv); + screenshots.push( + await withObsidianPage(session.remoteDebuggingPort, async (page) => { + const modal = page.locator(".modal-container").filter({ hasText: "Document History" }); + await modal.waitFor({ state: "visible", timeout: 10000 }); + await modal.getByText(contentMarker, { exact: false }).waitFor({ state: "visible", timeout: 10000 }); + assertEqual( + await modal.getByText("(At this revision, the file has been deleted)", { exact: false }).count(), + 0, + "The live successor revision was still displayed as deleted." + ); + assertEqual( + (await modal.locator(".history-rev-indicator").innerText()).trim(), + "Rev 3/3", + "Document History did not open at the new live successor revision." + ); + return await captureStep(page, screenshotDir, "04-restored-revision-in-history"); + }) + ); + + await mkdir(screenshotDir, { recursive: true }); + await writeFile( + reportPath, + `${JSON.stringify({ notePath, deletion, restored, screenshots }, null, 2)}\n`, + "utf-8" + ); + console.log("Document History soft-deletion restoration E2E passed."); + console.log(`Report: ${reportPath}`); + console.log(`Screenshots: ${screenshotDir}`); + } finally { + if (session) await session.app.stop(); + await vault.dispose(); + } +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.stack : error); + process.exit(1); +}); diff --git a/test/e2e-obsidian/scripts/run-focused.ts b/test/e2e-obsidian/scripts/run-focused.ts index 957d6fd8..4287388a 100644 --- a/test/e2e-obsidian/scripts/run-focused.ts +++ b/test/e2e-obsidian/scripts/run-focused.ts @@ -10,6 +10,7 @@ const focusedScenarios = new Set([ "dialog-mounts", "revision-repair", "document-history-nav", + "document-history-restore", "settings-ui", "review-harness", "p2p-pane", diff --git a/updates.md b/updates.md index cde0ba13..cc236260 100644 --- a/updates.md +++ b/updates.md @@ -12,6 +12,13 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi ## Unreleased +### Conflict handling and recovery + +#### Fixed + +- **Back to this revision** in Document History now restores the selected content as a new live database revision before reflecting it to the Vault. A readable revision restored after a logical deletion therefore remains restored through later synchronisation instead of being overwritten by the deletion. + - If the file changes while restoration is in progress, the operation stops instead of extending a stale revision. Existing conflicts remain available through **Inspect conflicts and file/database differences**. + ### Synchronisation and storage #### Improved @@ -59,8 +66,9 @@ Thank you for your patience. At last, it looks as though we can clear some of th - **Inspect conflicts and file/database differences** now reports local Metadata whose stored document ID does not match the ID derived from its recorded path. Ordinary scans leave unresolved entries and their corresponding Vault paths unchanged, while allowing consistently addressed Metadata for the same logical path to proceed normally. - When one live, unconflicted entry has an unambiguous target, its wrench menu can repair that one local Metadata document after separate confirmation. The target is written and verified before the mismatched source ID is removed; ambiguous or otherwise unsafe entries remain read-only. - + #### Fixed + - Fast Fetch now writes deletion tombstones to the local database without attempting to decrypt them. A tombstone has no encrypted payload, and decryption previously aborted the whole fetch at the first deleted document. New devices could not complete their initial sync on vaults that contain old deletions (Commonlib PR #108). - Thank you to @KennethLloyd for the contribution!