From a43882d743ed74c5f9bc042de37471320d573db0 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Sun, 8 Nov 2026 16:46:27 +0000 Subject: [PATCH 1/3] test: cover on-demand Fetch recovery in CLI and Obsidian --- package.json | 1 + src/apps/cli/README.md | 1 + test/README.md | 15 + test/e2e-cli/ci-tasks.json | 1 + test/e2e-cli/tasks.json | 3 + test/e2e-cli/test-fetch-on-demand-chunks.ts | 236 +++++++++++++ test/e2e-obsidian/README.md | 13 + .../scripts/fetch-on-demand-chunks.ts | 313 ++++++++++++++++++ test/e2e-obsidian/scripts/local-suite.ts | 1 + test/e2e-obsidian/scripts/run-focused.ts | 1 + updates.md | 7 + 11 files changed, 592 insertions(+) create mode 100644 test/e2e-cli/test-fetch-on-demand-chunks.ts create mode 100644 test/e2e-obsidian/scripts/fetch-on-demand-chunks.ts diff --git a/package.json b/package.json index 27e470d0..b9a459a3 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "test:e2e:obsidian:cli-help": "tsx test/e2e-obsidian/scripts/cli-help.ts", "test:e2e:obsidian:debug-ui": "tsx test/e2e-obsidian/scripts/debug-ui.ts", "test:e2e:obsidian:focused": "tsx test/e2e-obsidian/scripts/run-focused.ts", + "test:e2e:obsidian:fetch-on-demand-chunks": "tsx test/e2e-obsidian/scripts/fetch-on-demand-chunks.ts", "test:e2e:obsidian:smoke": "tsx test/e2e-obsidian/scripts/smoke.ts", "test:e2e:obsidian:onboarding-invitation": "tsx test/e2e-obsidian/scripts/onboarding-invitation.ts", "test:e2e:obsidian:dialog-mounts": "tsx test/e2e-obsidian/scripts/dialog-mounts.ts", diff --git a/src/apps/cli/README.md b/src/apps/cli/README.md index de254214..a400f7a0 100644 --- a/src/apps/cli/README.md +++ b/src/apps/cli/README.md @@ -94,6 +94,7 @@ livesync-cli [database-path] [command] [args...] - `fetch`: Replace the local database with the configured remote state, then reflect fetched files to the selected Vault. Existing local database state is discarded, so this command requires `--force`. - The selected local database suffix is saved automatically, including when `--write-settings` is omitted. - Differing local files can remain and be preserved as conflict branches alongside the fetched content. For recovery from an intact remote, preserve the existing Vault separately and Fetch into an empty Vault; `--force` does not force file overwriting. + - CouchDB content checks obtain missing Chunks through the ordinary retrieval policy, including when **Fetch chunks on demand** (`readChunksOnline`) omits them from replication. **Use Only Local Chunks** (`useOnlyLocalChunk`) still prevents direct remote requests. If required content remains unavailable, Fetch fails and retains its reconstruction record; use `--verbose` to inspect the affected path and retrieval setting. - `ls [prefix]`: List files currently stored in the local database. - `push `: Push a local file `` into the database at path ``. - `pull `: Pull a file `` from the database into local file ``. diff --git a/test/README.md b/test/README.md index 1541424c..241eb969 100644 --- a/test/README.md +++ b/test/README.md @@ -77,6 +77,21 @@ npm run test:e2e:docker:mirror --workspace self-hosted-livesync-cli The expected result is `PASS=7 FAIL=0`. These mirror suites use temporary local databases and need no CouchDB service. The complete `test:e2e:docker:all` command also runs the other Docker CLI suites and manages a disposable CouchDB fixture; use it to check the full Docker CI gate. Keep the ordinary-edit and unknown-provenance scenarios aligned between the Node.js and Bash suites. +## CLI Fetch with on-demand chunks + +Build the CLI, then run `node test/e2e-cli/run-task.ts test:fetch-on-demand-chunks` +with Node.js 26 and Docker available. The standard CLI CI suite includes this task. +It uses the existing disposable CouchDB test container and the CLI fixture +connection variables, with local test defaults when they are absent. + +The scenario fetches into an empty Vault, repeats Fetch with the same files, and +checks exact bytes, Metadata revisions, and removal of the reconstruction record. +It then prohibits direct Chunk requests, verifies that unavailable content keeps +Fetch failed and prevents daemon startup, and retries with requests permitted. +The final daemon startup must preserve the original bytes and revision. Commonlib +unit tests separately verify that the prohibition sends no direct request and +that equal-time, equal-size edits retain their recorded revision ancestry. + ## CLI Fetch database continuity The standard CLI CI suite and direct task runner execute diff --git a/test/e2e-cli/ci-tasks.json b/test/e2e-cli/ci-tasks.json index 0931f3f3..1c998856 100644 --- a/test/e2e-cli/ci-tasks.json +++ b/test/e2e-cli/ci-tasks.json @@ -16,6 +16,7 @@ "test:e2e-matrix:minio-enc0", "test:e2e-matrix:minio-enc1", "test:fetch-database-continuity", + "test:fetch-on-demand-chunks", "test:journal-chunk-recovery", "test:journal-receive-failure" ] diff --git a/test/e2e-cli/tasks.json b/test/e2e-cli/tasks.json index f773f442..d1cfdd91 100644 --- a/test/e2e-cli/tasks.json +++ b/test/e2e-cli/tasks.json @@ -32,6 +32,9 @@ "test:fetch-database-continuity": { "test": ["test-fetch-database-continuity.ts"] }, + "test:fetch-on-demand-chunks": { + "test": ["test-fetch-on-demand-chunks.ts"] + }, "test:remote-administration-exit-codes": { "test": ["test-remote-administration-exit-codes.ts"] }, diff --git a/test/e2e-cli/test-fetch-on-demand-chunks.ts b/test/e2e-cli/test-fetch-on-demand-chunks.ts new file mode 100644 index 00000000..57776e5b --- /dev/null +++ b/test/e2e-cli/test-fetch-on-demand-chunks.ts @@ -0,0 +1,236 @@ +import assert from "node:assert/strict"; +import { mkdir, readFile, stat, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { test } from "node:test"; +import { startCliInBackground, type BackgroundCliProcess } from "./helpers/backgroundCli.ts"; +import { runCli, runCliOrFail } from "./helpers/cli.ts"; +import { startCouchdb, stopCouchdb } from "./helpers/docker.ts"; +import { applyCouchdbSettings, initSettingsFile } from "./helpers/settings.ts"; +import { TempDir } from "./helpers/temp.ts"; + +function envOrDefault(keys: string[], fallback: string): string { + for (const key of keys) { + const value = process.env[key]?.trim(); + if (value) return value; + } + return fallback; +} + +async function setChunkRetrievalSettings(settingsPath: string, useOnlyLocalChunk: boolean): Promise { + const settings = JSON.parse(await readFile(settingsPath, "utf8")) as Record; + settings.readChunksOnline = true; + settings.useOnlyLocalChunk = useOnlyLocalChunk; + await writeFile(settingsPath, JSON.stringify(settings, null, 2)); +} + +async function assertMarkerAbsent(markerPath: string, message: string): Promise { + let exists = true; + try { + await stat(markerPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") exists = false; + else throw error; + } + assert.equal(exists, false, message); +} + +async function readMetadataRevision( + databasePath: string, + vaultPath: string, + settingsPath: string, + filePath: string +): Promise { + const infoText = await runCliOrFail( + databasePath, + "--vault", + vaultPath, + "--settings", + settingsPath, + "info", + filePath + ); + const info = JSON.parse(infoText) as { revision?: unknown }; + assert.equal(typeof info.revision, "string", "Metadata revision is missing for " + filePath + ": " + infoText); + return info.revision as string; +} + +async function stopDaemon(daemon: BackgroundCliProcess | undefined): Promise { + if (!daemon) return; + await daemon.stop().catch(() => {}); +} + +test( + "fetch: on-demand chunks preserve existing Vault Metadata and recover after an interrupted fetch", + { timeout: 180_000 }, + async () => { + await using workDir = await TempDir.create("livesync-cli-fetch-on-demand-chunks"); + + const couchdbUri = envOrDefault(["COUCHDB_URI", "hostname"], "http://127.0.0.1:5989").replace(/\/$/, ""); + const couchdbUser = envOrDefault(["COUCHDB_USER", "username"], "admin"); + const couchdbPassword = envOrDefault(["COUCHDB_PASSWORD", "password"], "testpassword"); + const dbPrefix = envOrDefault(["COUCHDB_DBNAME", "dbname"], "livesync-test-db-ci"); + const dbname = (dbPrefix + "-fetch-on-demand-chunks-" + Date.now() + "-" + Math.floor(Math.random() * 1_000_000)) + .toLowerCase(); + + const sourceDatabase = workDir.join("source-database"); + const receiverDatabase = workDir.join("receiver-database"); + const sourceVault = workDir.join("source-vault"); + const receiverVault = workDir.join("receiver-vault"); + const sourceSettings = workDir.join("source-settings.json"); + const receiverSettings = workDir.join("receiver-settings.json"); + const notePath = "notes/on-demand-chunks.md"; + const sourceNote = join(sourceVault, notePath); + const receiverNote = join(receiverVault, notePath); + const expectedBytes = Buffer.from("Repeated fetch must preserve this ASCII note.\n", "utf8"); + const sourceMarker = join(sourceDatabase, ".livesync", "reconstruction.json"); + const receiverMarker = join(receiverDatabase, ".livesync", "reconstruction.json"); + + await Promise.all([ + mkdir(sourceDatabase, { recursive: true }), + mkdir(receiverDatabase, { recursive: true }), + mkdir(sourceVault, { recursive: true }), + mkdir(receiverVault, { recursive: true }), + mkdir(join(sourceVault, "notes"), { recursive: true }), + ]); + await writeFile(sourceNote, expectedBytes); + + let daemon: BackgroundCliProcess | undefined; + try { + await startCouchdb(couchdbUri, couchdbUser, couchdbPassword, dbname); + await Promise.all([initSettingsFile(sourceSettings), initSettingsFile(receiverSettings)]); + await Promise.all([ + applyCouchdbSettings(sourceSettings, couchdbUri, couchdbUser, couchdbPassword, dbname, true), + applyCouchdbSettings(receiverSettings, couchdbUri, couchdbUser, couchdbPassword, dbname, true), + ]); + await Promise.all([ + setChunkRetrievalSettings(sourceSettings, false), + setChunkRetrievalSettings(receiverSettings, false), + ]); + + await runCliOrFail( + sourceDatabase, + "--vault", + sourceVault, + "--settings", + sourceSettings, + "rebuild", + "--force", + "--write-settings" + ); + await assertMarkerAbsent(sourceMarker, "Source rebuild should clear reconstruction.json"); + assert.deepEqual(await readFile(sourceNote), expectedBytes, "Source rebuild must preserve the Vault bytes"); + + await runCliOrFail( + receiverDatabase, + "--vault", + receiverVault, + "--settings", + receiverSettings, + "fetch", + "--force", + "--write-settings" + ); + assert.deepEqual(await readFile(receiverNote), expectedBytes, "Initial fetch must reproduce the note bytes"); + await assertMarkerAbsent(receiverMarker, "Initial fetch should clear reconstruction.json"); + const originalRevision = await readMetadataRevision(receiverDatabase, receiverVault, receiverSettings, notePath); + + await runCliOrFail( + receiverDatabase, + "--vault", + receiverVault, + "--settings", + receiverSettings, + "fetch", + "--force", + "--write-settings" + ); + assert.deepEqual(await readFile(receiverNote), expectedBytes, "Repeated fetch must preserve the Vault bytes"); + assert.equal( + await readMetadataRevision(receiverDatabase, receiverVault, receiverSettings, notePath), + originalRevision, + "Repeated fetch must retain the original Metadata revision" + ); + await assertMarkerAbsent(receiverMarker, "Repeated fetch should clear reconstruction.json"); + + await setChunkRetrievalSettings(receiverSettings, true); + const omittedChunkFetch = await runCli( + "-v", + receiverDatabase, + "--vault", + receiverVault, + "--settings", + receiverSettings, + "fetch", + "--force", + "--write-settings" + ); + assert.notEqual(omittedChunkFetch.code, 0, "Fetch must fail when local-only chunk reads omit the remote chunk"); + assert.match(omittedChunkFetch.combined, /useOnlyLocalChunk=true/); + assert.match(omittedChunkFetch.combined, /Could not reconcile/); + assert.deepEqual( + await readFile(receiverNote), + expectedBytes, + "A failed fetch must preserve the receiver Vault bytes" + ); + const failedMarker = JSON.parse(await readFile(receiverMarker, "utf8")) as { + command?: unknown; + phase?: unknown; + }; + assert.equal(failedMarker.command, "fetch", "Failed fetch should retain its recovery marker"); + assert.equal(failedMarker.phase, "running", "Failed fetch marker should remain in the running phase"); + + const blockedDaemon = await runCli( + receiverDatabase, + "--vault", + receiverVault, + "--settings", + receiverSettings, + "daemon" + ); + assert.notEqual(blockedDaemon.code, 0, "Daemon must refuse to start while fetch recovery is required"); + assert.match(blockedDaemon.combined, /interrupted fetch requires recovery/i); + + await setChunkRetrievalSettings(receiverSettings, false); + await runCliOrFail( + receiverDatabase, + "--vault", + receiverVault, + "--settings", + receiverSettings, + "fetch", + "--force", + "--write-settings" + ); + await assertMarkerAbsent(receiverMarker, "Successful recovery fetch should clear reconstruction.json"); + assert.deepEqual(await readFile(receiverNote), expectedBytes, "Recovery fetch must preserve the Vault bytes"); + assert.equal( + await readMetadataRevision(receiverDatabase, receiverVault, receiverSettings, notePath), + originalRevision, + "Recovery fetch must retain the original Metadata revision" + ); + + daemon = startCliInBackground( + receiverDatabase, + "--vault", + receiverVault, + "--settings", + receiverSettings, + "daemon" + ); + await daemon.waitUntilContains("[Daemon] Initial replication complete", 45_000); + assert.equal(await daemon.stop(), 0, daemon.combined); + daemon = undefined; + + assert.deepEqual(await readFile(receiverNote), expectedBytes, "Daemon startup must preserve the Vault bytes"); + assert.equal( + await readMetadataRevision(receiverDatabase, receiverVault, receiverSettings, notePath), + originalRevision, + "Daemon startup must not create an unnecessary Metadata revision" + ); + await assertMarkerAbsent(receiverMarker, "Recovered operation should leave reconstruction.json absent"); + } finally { + await stopDaemon(daemon); + await stopCouchdb().catch(() => {}); + } + } +); diff --git a/test/e2e-obsidian/README.md b/test/e2e-obsidian/README.md index e736d23d..db429612 100644 --- a/test/e2e-obsidian/README.md +++ b/test/e2e-obsidian/README.md @@ -85,6 +85,7 @@ npm run test:e2e:obsidian:focused -- stale-file-restart npm run test:e2e:obsidian:focused -- folder-batch npm run test:e2e:obsidian:focused -- security-seed-reconnect npm run test:e2e:obsidian:focused -- couchdb-http-cache +npm run test:e2e:obsidian:focused -- fetch-on-demand-chunks npm run test:e2e:obsidian:focused -- replication-status npm run test:e2e:obsidian:focused -- sync-guard ``` @@ -396,6 +397,18 @@ Set `E2E_OBSIDIAN_ARTIFACT_ROOT` to a directory containing the exact release `ma When these checks pass for the release artefacts, BRAT acceptance can be limited to installation or update, enabling the plug-in, and a cold start. Check any other changes included in that release using their own regression scenarios. The diagnostic export checks verify report payloads; they do not exercise the operating system's clipboard or download implementation on iOS or Android. +### Fetch with on-demand Chunks + +`fetch-on-demand-chunks` checks Standard Fetch finalisation for an existing Vault +when native Request API selection makes Fast Setup fall back from Streaming +replication. It uses encrypted CouchDB, obfuscated paths, internal Metadata +encryption, Markdown and attachment files, and on-demand Chunk retrieval. The +scenario requires local Chunks to be absent before the strict scan, then checks +remote delivery, exact file bytes, unchanged Metadata revisions, provenance, +readiness, and released suspension after finalisation. It covers the shared +failure path reported in #1329 and #1332; a Linux Obsidian run does not establish +verification on the reporter's iPadOS/WebKit device. + ### Environment variables - `OBSIDIAN_BINARY`: explicit Obsidian executable path. diff --git a/test/e2e-obsidian/scripts/fetch-on-demand-chunks.ts b/test/e2e-obsidian/scripts/fetch-on-demand-chunks.ts new file mode 100644 index 00000000..9a18e923 --- /dev/null +++ b/test/e2e-obsidian/scripts/fetch-on-demand-chunks.ts @@ -0,0 +1,313 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { + assertCouchDbReachable, + createCouchDbDatabase, + deleteCouchDbDatabase, + loadCouchDbConfig, + makeUniqueDatabaseName, +} from "../runner/couchdb.ts"; +import { requireObsidianBinary, requireObsidianCli } from "../runner/environment.ts"; +import { + createE2eCouchDbPluginData, + createE2eObsidianDeviceLocalState, + prepareRemote, + pushLocalChanges, + waitForLiveSyncCoreReady, +} from "../runner/liveSyncWorkflow.ts"; +import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts"; +import { captureObsidianPage, obsidianRemoteDebuggingPort, withObsidianPage } from "../runner/ui.ts"; +import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts"; + +type FileMetadata = { + path: string; + id: string; + revision: string; + children: string[]; +}; + +type InitialState = { + appVersion: string; + files: FileMetadata[]; +}; + +type FetchOutcome = { + scanResult: boolean; + finalised: boolean; + chunksPresentBeforeScan: number; + chunksPresentAfterScan: number; + totalChunkIds: number; + remoteChunkRequests: number[]; + appReady: boolean; + appSuspended: boolean; + suspendFileWatching: boolean; + files: Array<{ + path: string; + id: string; + revision: string; + conflicts: string[]; + provenanceRevision: string | null; + }>; +}; + +const paths = [ + "E2E/plain.md", + "E2E/drawing.excalidraw.md", + "E2E/attachment.pdf", + "E2E/image.png", +]; +const contents = [ + Buffer.from("# Existing note\n\n日本語 and English.\n".repeat(200)), + Buffer.from("# Excalidraw fixture\n\nExisting drawing data.\n".repeat(200)), + Buffer.from("%PDF-1.4\n" + "Synthetic attachment content.\n".repeat(2048)), + Buffer.from(Array.from({ length: 65536 }, (_, index) => (index * 13 + 17) % 256)), +]; +const digest = (bytes: ArrayLike) => createHash("sha256").update(Uint8Array.from(bytes)).digest("hex"); + +async function main(): Promise { + const binary = requireObsidianBinary(); + const cliBinary = requireObsidianCli(); + const couchDb = await loadCouchDbConfig(); + await assertCouchDbReachable(couchDb); + + const dbName = makeUniqueDatabaseName(couchDb.dbPrefix, "fetch-on-demand-chunks"); + const connection = { ...couchDb, dbName }; + let databaseCreated = false; + let vault: TemporaryVault | undefined; + let session: ObsidianLiveSyncSession | undefined; + + try { + await createCouchDbDatabase(couchDb, dbName); + databaseCreated = true; + vault = await createTemporaryVault("obsidian-livesync-fetch-on-demand-"); + const vaultPath = vault.path; + + const remoteDebuggingPort = obsidianRemoteDebuggingPort(process.env); + const sessionEnv = { + ...process.env, + E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT: String(remoteDebuggingPort), + }; + session = await startObsidianLiveSyncSession({ + binary, + cliBinary, + vault, + artifactRoot: process.env.E2E_OBSIDIAN_ARTIFACT_ROOT ?? process.cwd(), + pluginData: createE2eCouchDbPluginData(connection, { + encrypt: true, + E2EEAlgorithm: "v2", + passphrase: "synthetic-fetch-on-demand-fixture", + usePathObfuscation: true, + encryptInternalMetadata: true, + doctorProcessedVersion: "1.0.33", + idDerivationVersion: 0, + pathNormalizationVersion: 0, + useRequestAPI: true, + useOnlyLocalChunk: false, + readChunksOnline: true, + useIndexedDBAdapter: false, + concurrencyOfReadChunksOnline: 40, + minimumIntervalOfReadChunksOnline: 50, + showVerboseLog: true, + lessInformationInLog: false, + doNotSuspendOnFetching: false, + }), + localStorageEntries: createE2eObsidianDeviceLocalState(vault.name), + env: sessionEnv, + }); + await waitForLiveSyncCoreReady(cliBinary, session.cliEnv); + await prepareRemote(cliBinary, session.cliEnv); + + const evaluate = async (expression: string): Promise => { + if (!session) throw new Error("No active Obsidian session."); + let result: string | undefined; + await withObsidianPage(session.remoteDebuggingPort, async (page) => { + result = await page.evaluate(expression); + }); + if (result === undefined) throw new Error("The renderer returned no result."); + return JSON.parse(result) as T; + }; + const seededSettings = await evaluate>( + [ + "(async()=>{", + "const core=app.plugins.plugins['obsidian-livesync'].core;", + `const paths=${JSON.stringify(paths)};`, + `const contents=${JSON.stringify(contents.map((bytes) => bytes.toString("base64")))};`, + "await app.vault.createFolder('E2E');", + "for(let index=0;indexcharacter.charCodeAt(0));", + " if(paths[index].endsWith('.md')) await app.vault.create(paths[index],new TextDecoder().decode(bytes));", + " else await app.vault.createBinary(paths[index],bytes.buffer);", + "}", + "await core.services.fileProcessing.commitPendingFileEvents();", + "for(const path of paths){", + " const file=app.vault.getAbstractFileByPath(path);", + " if(!file) throw new Error('Fixture file is missing: '+path);", + " if(!(await core.fileHandler.storeFileToDB(file))) throw new Error('Could not store fixture '+path);", + "}", + "const settings=core.services.setting.currentSettings();", + "return JSON.stringify({", + " encrypt:settings.encrypt,E2EEAlgorithm:settings.E2EEAlgorithm,", + " usePathObfuscation:settings.usePathObfuscation,encryptInternalMetadata:settings.encryptInternalMetadata,", + " idDerivationVersion:settings.idDerivationVersion,pathNormalizationVersion:settings.pathNormalizationVersion,", + " readChunksOnline:settings.readChunksOnline,useOnlyLocalChunk:settings.useOnlyLocalChunk,", + " useRequestAPI:settings.useRequestAPI,useIndexedDBAdapter:settings.useIndexedDBAdapter", + "});", + "})()", + ].join("") + ); + assert.deepEqual(seededSettings, { + encrypt: true, + E2EEAlgorithm: "v2", + usePathObfuscation: true, + encryptInternalMetadata: true, + idDerivationVersion: 0, + pathNormalizationVersion: 0, + readChunksOnline: true, + useOnlyLocalChunk: false, + useRequestAPI: true, + useIndexedDBAdapter: false, + }); + + await pushLocalChanges(cliBinary, session.cliEnv); + const before = await evaluate( + [ + "(async()=>{", + "const core=app.plugins.plugins['obsidian-livesync'].core;", + `const paths=${JSON.stringify(paths)};`, + "return JSON.stringify({appVersion:core.services.API.getAppVersion(),files:", + "await Promise.all(paths.map(async path=>{", + " const meta=await core.localDatabase.getDBEntryMeta(path,undefined,true);", + " if(!meta) throw new Error('Fixture Metadata is missing: '+path);", + " return {path,id:meta._id,revision:meta._rev,children:meta.children};", + "}))});", + "})()", + ].join("") + ); + assert.equal(before.files.length, paths.length, "The remote fixture must contain all four files."); + assert.ok(before.files.every((file) => file.children.length > 0), "Every fixture file must have Chunks."); + const diskBefore = await Promise.all( + paths.map(async (path) => ({ path, hash: digest(await readFile(join(vaultPath, path))) })) + ); + assert.deepEqual( + diskBefore, + paths.map((path, index) => ({ path, hash: digest(contents[index]) })), + "The seeded Vault bytes must match the fixture." + ); + + const outcome = await evaluate( + [ + "(async()=>{", + "const core=app.plugins.plugins['obsidian-livesync'].core;", + `const paths=${JSON.stringify(paths)};`, + "await core.rebuilder.$fetchLocalDBFast(false);", + "const db=core.localDatabase.localDatabase;", + "const metadata=await Promise.all(paths.map(path=>core.localDatabase.getDBEntryMeta(path,undefined,true)));", + "const ids=[...new Set(metadata.flatMap(meta=>meta.children))];", + "const initialChunks=await db.allDocs({keys:ids,include_docs:true});", + "const chunksPresentBeforeScan=initialChunks.rows.filter(row=>row.doc&&!row.value?.deleted).length;", + "const replicator=core.services.replicator.getActiveReplicator();", + "if(!replicator||typeof replicator.fetchRemoteChunks!=='function') throw new Error('Remote Chunk fetch is unavailable');", + "const originalFetchRemoteChunks=replicator.fetchRemoteChunks;", + "const ownDescriptor=Object.getOwnPropertyDescriptor(replicator,'fetchRemoteChunks');", + "const remoteChunkRequests=[];", + "replicator.fetchRemoteChunks=async function(...args){", + " remoteChunkRequests.push(args[0]?.length??0);", + " return await originalFetchRemoteChunks.apply(this,args);", + "};", + "let scanResult;let finalised;", + "try{", + " scanResult=await core.services.vault.scanVault(true,true,false,{strictContentCheck:true});", + " finalised=await core.rebuilder.finishRebuild();", + "}finally{", + " if(ownDescriptor) Object.defineProperty(replicator,'fetchRemoteChunks',ownDescriptor);", + " else delete replicator.fetchRemoteChunks;", + "}", + "const afterMetadata=await Promise.all(paths.map(path=>core.localDatabase.getDBEntryMeta(path,undefined,true)));", + "const afterChunks=await db.allDocs({keys:ids,include_docs:true});", + "const provenance=core.services.keyValueDB.openSimpleStore('file-reflection-provenance-v1');", + "const files=await Promise.all(afterMetadata.map(async(meta,index)=>({", + " path:paths[index],id:meta._id,revision:meta._rev,", + " conflicts:(await db.get(meta._id,{conflicts:true}))._conflicts??[],", + " provenanceRevision:(await provenance.get(paths[index]))?.revision??null", + "})));", + "return JSON.stringify({scanResult,finalised,chunksPresentBeforeScan,", + " chunksPresentAfterScan:afterChunks.rows.filter(row=>row.doc&&!row.value?.deleted).length,", + " totalChunkIds:ids.length,remoteChunkRequests,", + " appReady:core.services.appLifecycle.isReady(),", + " appSuspended:core.services.appLifecycle.isSuspended(),", + " suspendFileWatching:core.services.setting.currentSettings().suspendFileWatching,files});", + "})()", + ].join("") + ); + const diskAfter = await Promise.all( + paths.map(async (path) => ({ path, hash: digest(await readFile(join(vaultPath, path))) })) + ); + + console.log( + JSON.stringify( + { + scenario: "fetch-on-demand-chunks", + appVersion: before.appVersion, + files: before.files.map(({ path, revision, children }) => ({ path, revision, chunkCount: children.length })), + scanResult: outcome.scanResult, + finalised: outcome.finalised, + chunksPresentBeforeScan: outcome.chunksPresentBeforeScan, + chunksPresentAfterScan: outcome.chunksPresentAfterScan, + remoteChunkRequestCount: outcome.remoteChunkRequests.length, + appReady: outcome.appReady, + appSuspended: outcome.appSuspended, + suspendFileWatching: outcome.suspendFileWatching, + }, + null, + 2 + ) + ); + + assert.equal(outcome.chunksPresentBeforeScan, 0, "The fetch must omit Chunks until strict scanning."); + assert.ok(outcome.totalChunkIds > 0, "The Metadata must reference Chunks."); + assert.equal(outcome.chunksPresentAfterScan, outcome.totalChunkIds, "Strict scanning must retrieve every Chunk."); + assert.equal(outcome.scanResult, true, "The strict Vault scan must succeed."); + assert.equal(outcome.finalised, true, "The Standard Fetch rebuild must finalise."); + assert.ok(outcome.remoteChunkRequests.some((count) => count > 0), "Strict scanning must request remote Chunks."); + assert.equal(outcome.appReady, true, "The application must become ready after Fetch."); + assert.equal(outcome.appSuspended, false, "The application must not remain suspended after Fetch."); + assert.equal(outcome.suspendFileWatching, false, "File watching must resume after Fetch."); + assert.deepEqual(diskAfter, diskBefore, "Fetch must preserve the exact physical Vault bytes."); + assert.equal(outcome.files.length, before.files.length); + for (let index = 0; index < before.files.length; index++) { + assert.equal(outcome.files[index].id, before.files[index].id, "Fetch must preserve the Metadata ID."); + assert.equal( + outcome.files[index].revision, + before.files[index].revision, + "Fetch must preserve the exact Metadata revision." + ); + assert.deepEqual(outcome.files[index].conflicts, [], "Fetch must not create Metadata conflicts."); + assert.equal( + outcome.files[index].provenanceRevision, + before.files[index].revision, + "File reflection provenance must identify the exact Metadata revision." + ); + } + } catch (error) { + if (session) { + await captureObsidianPage(session.remoteDebuggingPort, "fetch-on-demand-chunks-failure.png", async () => {}).catch( + (captureError) => console.error("Could not capture the failed Obsidian session:", captureError) + ); + } + throw error; + } finally { + try { + if (session) await session.app.stop(); + } finally { + try { + if (vault) await vault.dispose(); + } finally { + if (databaseCreated) await deleteCouchDbDatabase(couchDb, dbName); + } + } + } +} + +await main(); diff --git a/test/e2e-obsidian/scripts/local-suite.ts b/test/e2e-obsidian/scripts/local-suite.ts index 99893534..710ea62f 100644 --- a/test/e2e-obsidian/scripts/local-suite.ts +++ b/test/e2e-obsidian/scripts/local-suite.ts @@ -28,6 +28,7 @@ const testSteps: Step[] = [ { name: "vault reflection", args: ["run", "test:e2e:obsidian:vault-reflection"] }, { name: "joined devices with CouchDB", args: ["run", "test:e2e:obsidian:joined-devices"] }, { name: "CouchDB upload", args: ["run", "test:e2e:obsidian:couchdb-upload"] }, + { name: "on-demand Chunk fetch", args: ["run", "test:e2e:obsidian:fetch-on-demand-chunks"] }, { name: "manual CouchDB setup workflow", args: ["run", "test:e2e:obsidian:couchdb-manual-setup-workflow"], diff --git a/test/e2e-obsidian/scripts/run-focused.ts b/test/e2e-obsidian/scripts/run-focused.ts index 4bcd2b41..a2631111 100644 --- a/test/e2e-obsidian/scripts/run-focused.ts +++ b/test/e2e-obsidian/scripts/run-focused.ts @@ -26,6 +26,7 @@ const focusedScenarios = new Set([ "third-party-chunks", "couchdb-upload", "couchdb-http-cache", + "fetch-on-demand-chunks", "chunk-fetch-retry", "couchdb-manual-setup-workflow", "cli-to-obsidian-sync", diff --git a/updates.md b/updates.md index efd72f4f..d5dcdff5 100644 --- a/updates.md +++ b/updates.md @@ -12,10 +12,17 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi ## Unreleased +### Fixed + +- CouchDB Fetch can finish content checks for existing files when **Fetch chunks on demand** has omitted the required Chunks from replication. The same correction applies when Fast Setup falls back to Standard Fetch. (#1332) + - **Use Only Local Chunks** remains respected; unavailable content keeps the operation incomplete for retry. + ### CLI #### Fixed +- Repeating CouchDB Fetch into an existing Vault can complete with **Fetch chunks on demand** enabled. The content checks retrieve missing Chunks through the configured policy while preserving **Use Only Local Chunks** and unresolved-file recovery. (#1329) + - The CLI daemon now starts even when hidden directories in our Vault are unreadable. Vault scans skip these directories, which remain excluded from synchronisation. (#1325) - The daemon now detects files written into newly created folders during a burst of writes, including files renamed from temporary names, without requiring a restart. (#1328) From f6563609e66a7b0f49564f34c08c99f92f12894f Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Mon, 9 Nov 2026 02:58:08 +0000 Subject: [PATCH 2/3] test: align Fetch recovery diagnostics --- src/apps/cli/README.md | 2 +- test/e2e-cli/test-fetch-on-demand-chunks.ts | 9 +++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/apps/cli/README.md b/src/apps/cli/README.md index a400f7a0..29c66016 100644 --- a/src/apps/cli/README.md +++ b/src/apps/cli/README.md @@ -94,7 +94,7 @@ livesync-cli [database-path] [command] [args...] - `fetch`: Replace the local database with the configured remote state, then reflect fetched files to the selected Vault. Existing local database state is discarded, so this command requires `--force`. - The selected local database suffix is saved automatically, including when `--write-settings` is omitted. - Differing local files can remain and be preserved as conflict branches alongside the fetched content. For recovery from an intact remote, preserve the existing Vault separately and Fetch into an empty Vault; `--force` does not force file overwriting. - - CouchDB content checks obtain missing Chunks through the ordinary retrieval policy, including when **Fetch chunks on demand** (`readChunksOnline`) omits them from replication. **Use Only Local Chunks** (`useOnlyLocalChunk`) still prevents direct remote requests. If required content remains unavailable, Fetch fails and retains its reconstruction record; use `--verbose` to inspect the affected path and retrieval setting. + - CouchDB content checks obtain missing Chunks through the ordinary retrieval policy, including when **Fetch chunks on demand** (`readChunksOnline`) omits them from replication. **Use Only Local Chunks** (`useOnlyLocalChunk`) still prevents direct remote requests. If required content remains unavailable, Fetch fails and retains its reconstruction record; use `--verbose` to inspect the affected path and shortened revision. - `ls [prefix]`: List files currently stored in the local database. - `push `: Push a local file `` into the database at path ``. - `pull `: Pull a file `` from the database into local file ``. diff --git a/test/e2e-cli/test-fetch-on-demand-chunks.ts b/test/e2e-cli/test-fetch-on-demand-chunks.ts index 57776e5b..293b85cc 100644 --- a/test/e2e-cli/test-fetch-on-demand-chunks.ts +++ b/test/e2e-cli/test-fetch-on-demand-chunks.ts @@ -1,3 +1,4 @@ +import { displayRev } from "@vrtmrz/livesync-commonlib/compat/common/utils"; import assert from "node:assert/strict"; import { mkdir, readFile, stat, writeFile } from "node:fs/promises"; import { join } from "node:path"; @@ -165,8 +166,12 @@ test( "--write-settings" ); assert.notEqual(omittedChunkFetch.code, 0, "Fetch must fail when local-only chunk reads omit the remote chunk"); - assert.match(omittedChunkFetch.combined, /useOnlyLocalChunk=true/); - assert.match(omittedChunkFetch.combined, /Could not reconcile/); + assert.ok( + omittedChunkFetch.combined.includes( + `Could not reconcile ${notePath}: content for current database revision ${displayRev(originalRevision)} is unavailable;` + ), + omittedChunkFetch.combined + ); assert.deepEqual( await readFile(receiverNote), expectedBytes, From 042249df065993b3025d8b0096da8c5165d14739 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Mon, 9 Nov 2026 10:14:33 +0000 Subject: [PATCH 3/3] chore: adopt Commonlib 0.4.1 --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 277973d8..5f767c6e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,7 @@ "@smithy/types": "^4.14.3", "@smithy/util-retry": "^4.4.5", "@vrtmrz/browser-ui-kit": "0.1.0", - "@vrtmrz/livesync-commonlib": "0.4.0", + "@vrtmrz/livesync-commonlib": "0.4.1", "@vrtmrz/obsidian-plugin-kit": "0.1.7", "@vrtmrz/ui-interactions": "0.1.2", "@vrtmrz/value-store": "0.1.0", @@ -4081,9 +4081,9 @@ } }, "node_modules/@vrtmrz/livesync-commonlib": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.4.0.tgz", - "integrity": "sha512-2bhkrd0rRWSiy90rjOxpaO01Ei2V9VSwifzv4ePUttxrzt195zjWNZCzW9ZMvJvAtyPoifShilW0J4kig4oKFA==", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.4.1.tgz", + "integrity": "sha512-lp0RLvFIIO2qJbWqxFMGhPZpe3SjydfhJLQkEtou8Db7ls7H9Op9fYIhRA08lr6CRWymZ5HbqHf2xdDmE3YB3g==", "license": "MIT", "dependencies": { "@aws-crypto/sha256-js": "5.2.0", diff --git a/package.json b/package.json index b9a459a3..24dcb1f4 100644 --- a/package.json +++ b/package.json @@ -212,7 +212,7 @@ "@smithy/types": "^4.14.3", "@smithy/util-retry": "^4.4.5", "@vrtmrz/browser-ui-kit": "0.1.0", - "@vrtmrz/livesync-commonlib": "0.4.0", + "@vrtmrz/livesync-commonlib": "0.4.1", "@vrtmrz/obsidian-plugin-kit": "0.1.7", "@vrtmrz/ui-interactions": "0.1.2", "@vrtmrz/value-store": "0.1.0",