From f47db4f256dd14d0b15ee46d52c4fb3dfc0bb79d Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Sat, 17 Oct 2026 13:24:53 +0000 Subject: [PATCH 1/2] Preserve unchanged-file reflection after revision history pruning --- devs.md | 12 ++ package-lock.json | 8 +- package.json | 2 +- .../cli/serviceModules/CLIServiceModules.ts | 1 + .../serviceModules/FSAPIServiceModules.ts | 1 + .../ReviewHarness/reviewHarnessContract.ts | 8 + .../reviewHarnessContract.unit.spec.ts | 6 + .../ReviewHarness/reviewHarnessController.ts | 3 + .../reviewHarnessController.unit.spec.ts | 42 +++++ .../reviewHarnessFileContentHashBenchmark.ts | 149 ++++++++++++++++++ ...rnessFileContentHashBenchmark.unit.spec.ts | 134 ++++++++++++++++ ...wHarnessFileContentHashBenchmarkRuntime.ts | 20 +++ ...leContentHashBenchmarkRuntime.unit.spec.ts | 15 ++ src/main.ts | 1 + src/serviceFeatures/useReviewHarness.ts | 7 + test/e2e-obsidian/README.md | 2 +- test/e2e-obsidian/scripts/review-harness.ts | 56 +++++++ .../scripts/stale-file-restart.ts | 66 ++++++-- updates.md | 9 ++ 19 files changed, 524 insertions(+), 18 deletions(-) create mode 100644 src/features/ReviewHarness/reviewHarnessFileContentHashBenchmark.ts create mode 100644 src/features/ReviewHarness/reviewHarnessFileContentHashBenchmark.unit.spec.ts create mode 100644 src/features/ReviewHarness/reviewHarnessFileContentHashBenchmarkRuntime.ts create mode 100644 src/features/ReviewHarness/reviewHarnessFileContentHashBenchmarkRuntime.unit.spec.ts diff --git a/devs.md b/devs.md index bbeaf1be..e5648637 100644 --- a/devs.md +++ b/devs.md @@ -245,6 +245,18 @@ Where `performance.memory` is available, the report includes approximate JavaScr The developer-only adapter in `src/features/ReviewHarness/reviewHarnessIdBenchmarkRuntime.ts` imports `HashManager` from Commonlib's public `/hashing` entry. Compilation, packed-package checks, and runtime tests cover this boundary. The algorithms remain owned by Commonlib. +#### File content hash performance measurements + +Commonlib owns the algorithm choice and provenance contract. Its [file-reflection content hash decision](https://github.com/vrtmrz/livesync-commonlib/blob/main/docs/file-reflection-content-hash.md) records the SHA-256 rationale, xxHash64 comparison, measured costs, and conditions for reconsideration. This section describes the host-owned measurement procedure. + +Choose **Run** beside **File content hash performance** in the Review Harness. **Automatic** does not run this measurement; **Full review** includes it. + +The measurement compares the Self-hosted LiveSync SHA-256 file content hash helper with `xxHash64` for fixed in-memory `Blob` inputs of 4 KiB, 64 KiB, 1 MiB, and 50 MiB. Each input begins with the same deterministic byte sequence. Input construction is excluded from the timed samples; `xxHash64` engine initialisation is timed and reported separately. One 4-KiB warm-up call per algorithm is also measured separately and excluded from samples. SHA-256 uses the same helper as the product's file content hash path; `xxHash64` timing includes `Blob.arrayBuffer()` and `h64Raw`. Calls run one at a time, alternate algorithm order between samples, and yield to the interface between calls. The 50-MiB input uses the same three samples as the other sizes. + +The report gives the median and range for each algorithm and input size. Where `performance.memory` is available, it also records approximate JavaScript heap samples before, during, and after measurement. These samples may include other Obsidian activity and garbage collection; they are neither total process RAM nor a guaranteed peak. Unsupported devices explicitly report that heap measurements are unavailable. + +The scenario uses only fixed in-memory inputs. It does not read or write the Vault, database, settings, remote storage, or user content. It does not expose a hash-method setting or use a fixed performance threshold. The developer-only adapter in `src/features/ReviewHarness/reviewHarnessFileContentHashBenchmarkRuntime.ts` imports `computeFileContentHash` from Commonlib's public `/hashing` entry and `xxhashNew()` from `octagonal-wheels`. The benchmark compares the product helper with that package implementation; it does not change the hash method used by LiveSync. + #### Logs - Use `this._log(msg, LOG_LEVEL_INFO)` in modules (automatically prefixes with module name) diff --git a/package-lock.json b/package-lock.json index 6aadafc4..e607283a 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.1.40", + "@vrtmrz/livesync-commonlib": "0.1.42", "@vrtmrz/obsidian-plugin-kit": "0.1.4", "@vrtmrz/ui-interactions": "0.1.2", "diff-match-patch": "^1.0.5", @@ -4389,9 +4389,9 @@ } }, "node_modules/@vrtmrz/livesync-commonlib": { - "version": "0.1.40", - "resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.40.tgz", - "integrity": "sha512-GVskcVFoQIpb45rR+QT9Wjtdqc6oAxYz5IB2WeJwusWZ8lTmqVrba+6YD9FTtsbzqbPOyJ/LdQxnET7SUexVtw==", + "version": "0.1.42", + "resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.42.tgz", + "integrity": "sha512-LyWl0Mkrj9UsQXV0nOUaYJybcN3wLkpAAxCeHMZYOEizaUTQ7ezN+FgzN+OQxDRA8UXWnoatxh719NWzOwBpBw==", "license": "MIT", "dependencies": { "@aws-sdk/client-s3": "^3.808.0", diff --git a/package.json b/package.json index 16dc3c0b..fb7edb0b 100644 --- a/package.json +++ b/package.json @@ -191,7 +191,7 @@ "@smithy/types": "^4.14.3", "@smithy/util-retry": "^4.4.5", "@vrtmrz/browser-ui-kit": "0.1.0", - "@vrtmrz/livesync-commonlib": "0.1.40", + "@vrtmrz/livesync-commonlib": "0.1.42", "@vrtmrz/obsidian-plugin-kit": "0.1.4", "@vrtmrz/ui-interactions": "0.1.2", "diff-match-patch": "^1.0.5", diff --git a/src/apps/cli/serviceModules/CLIServiceModules.ts b/src/apps/cli/serviceModules/CLIServiceModules.ts index 2430cfaa..e87f864e 100644 --- a/src/apps/cli/serviceModules/CLIServiceModules.ts +++ b/src/apps/cli/serviceModules/CLIServiceModules.ts @@ -102,6 +102,7 @@ export function initialiseServiceModulesCLI( events: services.context.events, API: services.API, database: services.database, + path: services.path, appLifecycle: services.appLifecycle, setting: services.setting, remote: services.remote, diff --git a/src/apps/webapp/serviceModules/FSAPIServiceModules.ts b/src/apps/webapp/serviceModules/FSAPIServiceModules.ts index b9054cec..58b61d45 100644 --- a/src/apps/webapp/serviceModules/FSAPIServiceModules.ts +++ b/src/apps/webapp/serviceModules/FSAPIServiceModules.ts @@ -93,6 +93,7 @@ export function initialiseServiceModulesFSAPI( events: services.context.events, API: services.API, database: services.database, + path: services.path, appLifecycle: services.appLifecycle, setting: services.setting, remote: services.remote, diff --git a/src/features/ReviewHarness/reviewHarnessContract.ts b/src/features/ReviewHarness/reviewHarnessContract.ts index 3925857d..fa953f48 100644 --- a/src/features/ReviewHarness/reviewHarnessContract.ts +++ b/src/features/ReviewHarness/reviewHarnessContract.ts @@ -37,6 +37,14 @@ export const REVIEW_HARNESS_SCENARIOS = [ mode: "automatic", access: "read-only", }, + { + id: "file-content-hash-performance", + title: "File content hash performance", + description: + "Compares SHA-256 and xxHash64 on fixed in-memory Blob inputs of 4 KiB, 64 KiB, 1 MiB, and 50 MiB. Input construction is excluded from hash timings; engine initialisation and warm-up are reported separately. Keep Obsidian in the foreground.", + mode: "manual", + access: "read-only", + }, ] as const; export const REVIEW_HARNESS_SCENARIO_IDS = REVIEW_HARNESS_SCENARIOS.map(({ id }) => id); diff --git a/src/features/ReviewHarness/reviewHarnessContract.unit.spec.ts b/src/features/ReviewHarness/reviewHarnessContract.unit.spec.ts index bc33c775..127a6c71 100644 --- a/src/features/ReviewHarness/reviewHarnessContract.unit.spec.ts +++ b/src/features/ReviewHarness/reviewHarnessContract.unit.spec.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { NEW_VAULT_SETTINGS, type SettingsMigrationState } from "@vrtmrz/livesync-commonlib/settings"; import type { CompatibilityPause } from "@/common/databaseCompatibility.ts"; import { + REVIEW_HARNESS_SCENARIOS, REVIEW_HARNESS_SCENARIO_IDS, formatReviewHarnessReport, inspectCompatibilityReview, @@ -76,7 +77,12 @@ describe("Review Harness contract", () => { "compatibility-review", "vault-round-trip", "id-generation-performance", + "file-content-hash-performance", ]); + expect(REVIEW_HARNESS_SCENARIOS.find(({ id }) => id === "file-content-hash-performance")).toMatchObject({ + mode: "manual", + access: "read-only", + }); }); it("checks that an existing Vault exposes the seven synchronisation choices as booleans", () => { diff --git a/src/features/ReviewHarness/reviewHarnessController.ts b/src/features/ReviewHarness/reviewHarnessController.ts index 12c0e370..2bde2f58 100644 --- a/src/features/ReviewHarness/reviewHarnessController.ts +++ b/src/features/ReviewHarness/reviewHarnessController.ts @@ -22,6 +22,7 @@ export interface ReviewHarnessRuntime { openCompatibilityReview(): Promise; runVaultRoundTrip(): Promise; runIdBenchmark(): Promise; + runFileContentHashBenchmark(): Promise; readContinuation(): string | null; writeContinuation(value: string): void; deleteContinuation(): void; @@ -162,6 +163,8 @@ export class ReviewHarnessController { result = await this.runtime.runVaultRoundTrip(); } else if (id === "id-generation-performance") { result = await this.runtime.runIdBenchmark(); + } else if (id === "file-content-hash-performance") { + result = await this.runtime.runFileContentHashBenchmark(); } else { const inspection = this.inspectCompatibilityReview(); result = diff --git a/src/features/ReviewHarness/reviewHarnessController.unit.spec.ts b/src/features/ReviewHarness/reviewHarnessController.unit.spec.ts index dee43186..cce28869 100644 --- a/src/features/ReviewHarness/reviewHarnessController.unit.spec.ts +++ b/src/features/ReviewHarness/reviewHarnessController.unit.spec.ts @@ -85,6 +85,11 @@ function createRuntime(): ReviewHarnessRuntime & { detail: "ID generation measurements completed.", observations: ["Chunk 256 B: 1000 IDs total=43.00 ms; per ID=0.0430 ms"], })), + runFileContentHashBenchmark: vi.fn(async () => ({ + status: "passed" as const, + detail: "File content hash measurements completed.", + observations: ["SHA-256, 4 KiB: median=1.00 ms; range=1.00–1.00 ms"], + })), readContinuation() { return this.continuation; }, @@ -121,6 +126,7 @@ describe("ReviewHarnessController", () => { expect(controller.snapshot().results["settings-lifecycle"].status).toBe("passed"); expect(controller.snapshot().results["vault-round-trip"].status).toBe("idle"); expect(controller.snapshot().results["compatibility-review"].status).toBe("idle"); + expect(controller.snapshot().results["file-content-hash-performance"].status).toBe("idle"); }); it("runs the Vault fixture scenario only when it is selected explicitly", async () => { @@ -185,6 +191,42 @@ describe("ReviewHarnessController", () => { expect(runtime.reportError).toHaveBeenCalledOnce(); }); + it("runs file content hash measurements on request without running them automatically", async () => { + const runtime = createRuntime(); + const controller = new ReviewHarnessController(runtime); + + await controller.runAutomaticScenarios(); + expect(runtime.runFileContentHashBenchmark).not.toHaveBeenCalled(); + + await controller.runScenario("file-content-hash-performance"); + + expect(runtime.runFileContentHashBenchmark).toHaveBeenCalledOnce(); + expect(controller.snapshot().results["file-content-hash-performance"]).toMatchObject({ status: "passed" }); + expect(runtime.runVaultRoundTrip).not.toHaveBeenCalled(); + }); + + it("includes file content hash measurements in Full review", async () => { + const runtime = createRuntime(); + const controller = new ReviewHarnessController(runtime); + + await controller.runAllScenarios(); + + expect(runtime.runFileContentHashBenchmark).toHaveBeenCalledOnce(); + expect(controller.snapshot().results["file-content-hash-performance"].status).toBe("passed"); + }); + + it("excludes an unexpected file content hash error from the copied report", async () => { + const runtime = createRuntime(); + runtime.runFileContentHashBenchmark = vi.fn().mockRejectedValue(new Error("private benchmark content")); + const controller = new ReviewHarnessController(runtime); + + await controller.runScenario("file-content-hash-performance"); + + expect(controller.snapshot().results["file-content-hash-performance"].status).toBe("failed"); + expect(controller.createReport()).not.toContain("private benchmark content"); + expect(runtime.reportError).toHaveBeenCalledOnce(); + }); + it("does not overlap an ID measurement with another scenario", async () => { const runtime = createRuntime(); let finish!: () => void; diff --git a/src/features/ReviewHarness/reviewHarnessFileContentHashBenchmark.ts b/src/features/ReviewHarness/reviewHarnessFileContentHashBenchmark.ts new file mode 100644 index 00000000..7ff2d604 --- /dev/null +++ b/src/features/ReviewHarness/reviewHarnessFileContentHashBenchmark.ts @@ -0,0 +1,149 @@ +import type { ReviewHarnessScenarioResult } from "./reviewHarnessTypes"; + +export const REVIEW_HARNESS_FILE_CONTENT_HASH_INPUTS = [ + { label: "4 KiB", bytes: 4 * 1024 }, + { label: "64 KiB", bytes: 64 * 1024 }, + { label: "1 MiB", bytes: 1024 * 1024 }, + { label: "50 MiB", bytes: 50 * 1024 * 1024 }, +] as const; + +const CONTENT_BLOCK_BYTES = 64 * 1024; +const SAMPLE_COUNT = 3; + +export interface FileContentHashBenchmarkOperations { + readonly xxHashInitialisationMs: number; + createInput(bytes: number): Blob; + sha256(content: Blob): Promise<`sha256:${string}`>; + xxHash64(content: Blob): Promise; +} + +type BenchmarkPerformance = Pick & { + readonly memory?: { readonly usedJSHeapSize: number }; +}; + +type HashAlgorithm = "SHA-256" | "xxHash64"; + +function readHeap(clock: BenchmarkPerformance): number | undefined { + try { + const bytes = clock.memory?.usedJSHeapSize; + return typeof bytes === "number" && Number.isFinite(bytes) && bytes >= 0 ? bytes : undefined; + } catch { + return undefined; + } +} + +function summary(samples: readonly number[]): string { + const sorted = [...samples].sort((a, b) => a - b); + const middle = sorted[Math.floor(sorted.length / 2)]; + return `median=${middle.toFixed(2)} ms; range=${sorted[0].toFixed(2)}–${sorted[sorted.length - 1].toFixed(2)} ms`; +} + +function hashKey(hash: `sha256:${string}` | bigint): string { + if (typeof hash === "bigint") return hash.toString(16); + return hash; +} + +export function createFixedFileContentHashInput(bytes: number): Blob { + const block = new Uint8Array(CONTENT_BLOCK_BYTES); + for (let index = 0; index < block.length; index++) block[index] = (index * 31 + 17) & 0xff; + + const parts: BlobPart[] = []; + for (let remaining = bytes; remaining > 0; remaining -= CONTENT_BLOCK_BYTES) { + parts.push(block.subarray(0, Math.min(remaining, CONTENT_BLOCK_BYTES))); + } + return new Blob(parts); +} + +export async function runReviewHarnessFileContentHashBenchmark( + operations: FileContentHashBenchmarkOperations, + clock: BenchmarkPerformance = performance, + yieldControl: () => Promise = () => new Promise((resolve) => window.setTimeout(resolve, 0)) +): Promise { + if (!Number.isFinite(operations.xxHashInitialisationMs) || operations.xxHashInitialisationMs < 0) { + throw new Error("The xxHash64 initialisation time is invalid."); + } + + const before = readHeap(clock); + let highest = before; + const sampleHeap = () => { + const value = readHeap(clock); + if (value !== undefined) highest = Math.max(highest ?? value, value); + return value; + }; + + const inputs: { readonly label: string; readonly bytes: number; readonly content: Blob }[] = []; + for (const { label, bytes } of REVIEW_HARNESS_FILE_CONTENT_HASH_INPUTS) { + await yieldControl(); + inputs.push({ label, bytes, content: operations.createInput(bytes) }); + sampleHeap(); + } + + const hash = (algorithm: HashAlgorithm, content: Blob) => + algorithm === "SHA-256" ? operations.sha256(content) : operations.xxHash64(content); + const warmupTimes = new Map(); + const signatures = new Map(); + const checkHash = (algorithm: HashAlgorithm, label: string, result: `sha256:${string}` | bigint): void => { + const value = hashKey(result); + const key = `${algorithm}:${label}`; + const previous = signatures.get(key); + if (previous !== undefined && previous !== value) { + throw new Error(`The ${algorithm} result changed for fixed in-memory content.`); + } + signatures.set(key, value); + }; + + for (const algorithm of ["SHA-256", "xxHash64"] as const) { + await yieldControl(); + const started = clock.now(); + const result = await hash(algorithm, inputs[0].content); + const elapsed = clock.now() - started; + checkHash(algorithm, inputs[0].label, result); + warmupTimes.set(algorithm, elapsed); + sampleHeap(); + } + + const observations = [ + "Fixed in-memory Blob inputs contain the same deterministic byte sequence at 4 KiB, 64 KiB, 1 MiB, and 50 MiB. Input construction is excluded from timings.", + `xxHash64 engine initialisation: ${operations.xxHashInitialisationMs.toFixed(2)} ms; measured separately.`, + `Warm-up on 4 KiB, one call per algorithm: SHA-256=${warmupTimes.get("SHA-256")!.toFixed(2)} ms; xxHash64=${warmupTimes.get("xxHash64")!.toFixed(2)} ms; excluded from samples.`, + "Each algorithm has three samples per input. SHA-256 uses the Self-hosted LiveSync file content hash helper; xxHash64 timings include Blob.arrayBuffer() and h64Raw. Calls run serially, with alternating algorithm order and a UI yield between calls.", + ]; + + for (const input of inputs) { + const samples: Record = { "SHA-256": [], xxHash64: [] }; + for (let sample = 0; sample < SAMPLE_COUNT; sample++) { + const order: readonly HashAlgorithm[] = + sample % 2 === 0 ? ["SHA-256", "xxHash64"] : ["xxHash64", "SHA-256"]; + for (const algorithm of order) { + await yieldControl(); + const started = clock.now(); + const result = await hash(algorithm, input.content); + const elapsed = clock.now() - started; + checkHash(algorithm, input.label, result); + if (!Number.isFinite(elapsed) || elapsed < 0) { + throw new Error(`The ${algorithm} measurement returned an invalid duration.`); + } + samples[algorithm].push(elapsed); + sampleHeap(); + } + } + for (const algorithm of ["SHA-256", "xxHash64"] as const) { + observations.push(`${algorithm}, ${input.label}: ${summary(samples[algorithm])}.`); + } + } + + const after = sampleHeap(); + if (highest === undefined) { + observations.push("JavaScript heap: unavailable on this device."); + } else { + const mib = (bytes: number | undefined) => + bytes === undefined ? "unavailable" : `${(bytes / 1048576).toFixed(2)} MiB`; + observations.push( + `JavaScript heap: before=${mib(before)}; highest sampled=${mib(highest)}; after=${mib(after)}.` + ); + } + observations.push( + "Heap samples are approximate, may include other Obsidian work, and are affected by garbage collection. They are neither total app RAM nor a true peak." + ); + return { status: "passed", detail: "File content hash measurements completed.", observations }; +} diff --git a/src/features/ReviewHarness/reviewHarnessFileContentHashBenchmark.unit.spec.ts b/src/features/ReviewHarness/reviewHarnessFileContentHashBenchmark.unit.spec.ts new file mode 100644 index 00000000..a3ffc39a --- /dev/null +++ b/src/features/ReviewHarness/reviewHarnessFileContentHashBenchmark.unit.spec.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from "vitest"; +import { + REVIEW_HARNESS_FILE_CONTENT_HASH_INPUTS, + createFixedFileContentHashInput, + runReviewHarnessFileContentHashBenchmark, + type FileContentHashBenchmarkOperations, +} from "./reviewHarnessFileContentHashBenchmark"; + +function fixture() { + let elapsed = 0; + let activeCalls = 0; + let maximumConcurrentCalls = 0; + let yieldCount = 0; + const contentSizes = new WeakMap(); + const contentBySize = new Map(); + const callCounts = new Map(); + const observedInputs = new Map(); + const operations: FileContentHashBenchmarkOperations = { + xxHashInitialisationMs: 12.5, + createInput: (bytes) => { + elapsed += 10000; + const content = new Blob([new Uint8Array(4)]); + contentSizes.set(content, bytes); + contentBySize.set(bytes, content); + return content; + }, + sha256: async (content) => { + await measure("SHA-256", content); + return `sha256:${contentSizes.get(content)!}`; + }, + xxHash64: async (content) => { + await measure("xxHash64", content); + return BigInt(contentSizes.get(content)!); + }, + }; + + async function measure(algorithm: string, content: Blob): Promise { + activeCalls++; + maximumConcurrentCalls = Math.max(maximumConcurrentCalls, activeCalls); + const bytes = contentSizes.get(content)!; + const key = `${algorithm}:${bytes}`; + const count = callCounts.get(key) ?? 0; + callCounts.set(key, count + 1); + observedInputs.set(key, [...(observedInputs.get(key) ?? []), content]); + const durations = + bytes === 4 * 1024 + ? algorithm === "SHA-256" + ? [100, 1, 3, 5] + : [200, 11, 13, 15] + : algorithm === "SHA-256" + ? [1, 3, 5] + : [11, 13, 15]; + await Promise.resolve(); + elapsed += durations[count]; + activeCalls--; + } + + return { + operations, + now: () => elapsed, + yieldControl: async () => { + yieldCount++; + await Promise.resolve(); + }, + inputs: () => ({ contentBySize, callCounts, observedInputs, yieldCount, maximumConcurrentCalls }), + }; +} + +describe("Review Harness file content hash measurements", () => { + it("measures fixed Blob inputs with separate initialisation, warm-up, and sample ranges", async () => { + const f = fixture(); + const result = await runReviewHarnessFileContentHashBenchmark(f.operations, { now: f.now }, f.yieldControl); + const report = result.observations.join("\n"); + + expect(result.status).toBe("passed"); + expect(result.detail).toBe("File content hash measurements completed."); + expect([...f.inputs().contentBySize.keys()]).toEqual( + REVIEW_HARNESS_FILE_CONTENT_HASH_INPUTS.map(({ bytes }) => bytes) + ); + expect(Object.fromEntries(f.inputs().callCounts)).toEqual({ + "SHA-256:4096": 4, + "xxHash64:4096": 4, + ...Object.fromEntries( + REVIEW_HARNESS_FILE_CONTENT_HASH_INPUTS.slice(1).flatMap(({ bytes }) => [ + [`SHA-256:${bytes}`, 3], + [`xxHash64:${bytes}`, 3], + ]) + ), + }); + expect(f.inputs().yieldCount).toBe(30); + expect(f.inputs().maximumConcurrentCalls).toBe(1); + for (const { bytes } of REVIEW_HARNESS_FILE_CONTENT_HASH_INPUTS) { + for (const algorithm of ["SHA-256", "xxHash64"]) { + expect(f.inputs().observedInputs.get(`${algorithm}:${bytes}`)?.every( + (content) => content === f.inputs().contentBySize.get(bytes) + )).toBe(true); + } + } + expect(report).toContain("xxHash64 engine initialisation: 12.50 ms; measured separately."); + expect(report).toContain( + "Warm-up on 4 KiB, one call per algorithm: SHA-256=100.00 ms; xxHash64=200.00 ms; excluded from samples." + ); + expect(report).toContain("SHA-256, 4 KiB: median=3.00 ms; range=1.00–5.00 ms."); + expect(report).toContain("xxHash64, 4 KiB: median=13.00 ms; range=11.00–15.00 ms."); + expect(report).toContain("Each algorithm has three samples per input."); + expect(report).not.toContain("sha256:4096"); + expect(report).not.toContain("4096n"); + }); + + it("uses the same deterministic byte sequence for short and long inputs", async () => { + const shortInput = createFixedFileContentHashInput(4 * 1024); + const longInput = createFixedFileContentHashInput(64 * 1024); + + expect(shortInput.size).toBe(4 * 1024); + expect(longInput.size).toBe(64 * 1024); + expect(new Uint8Array(await longInput.slice(0, shortInput.size).arrayBuffer())).toEqual( + new Uint8Array(await shortInput.arrayBuffer()) + ); + }); + + it("propagates hash failures without turning a partial run into a passed measurement", async () => { + const f = fixture(); + const operations = { + ...f.operations, + sha256: async () => { + throw new Error("synthetic hashing failure"); + }, + }; + + await expect( + runReviewHarnessFileContentHashBenchmark(operations, { now: f.now }, f.yieldControl) + ).rejects.toThrow("synthetic hashing failure"); + }); +}); diff --git a/src/features/ReviewHarness/reviewHarnessFileContentHashBenchmarkRuntime.ts b/src/features/ReviewHarness/reviewHarnessFileContentHashBenchmarkRuntime.ts new file mode 100644 index 00000000..8d6b8831 --- /dev/null +++ b/src/features/ReviewHarness/reviewHarnessFileContentHashBenchmarkRuntime.ts @@ -0,0 +1,20 @@ +import { computeFileContentHash } from "@vrtmrz/livesync-commonlib/hashing"; +import { xxhashNew } from "octagonal-wheels/hash/xxhash.js"; +import { + createFixedFileContentHashInput, + type FileContentHashBenchmarkOperations, +} from "./reviewHarnessFileContentHashBenchmark"; + +export async function createFileContentHashBenchmarkOperations( + clock: Pick = performance +): Promise { + const started = clock.now(); + const xxHash = await xxhashNew(); + const xxHashInitialisationMs = clock.now() - started; + return { + xxHashInitialisationMs, + createInput: createFixedFileContentHashInput, + sha256: (content) => computeFileContentHash(content), + xxHash64: async (content) => xxHash.h64Raw(new Uint8Array(await content.arrayBuffer())), + }; +} diff --git a/src/features/ReviewHarness/reviewHarnessFileContentHashBenchmarkRuntime.unit.spec.ts b/src/features/ReviewHarness/reviewHarnessFileContentHashBenchmarkRuntime.unit.spec.ts new file mode 100644 index 00000000..a5d6db38 --- /dev/null +++ b/src/features/ReviewHarness/reviewHarnessFileContentHashBenchmarkRuntime.unit.spec.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { createFileContentHashBenchmarkOperations } from "./reviewHarnessFileContentHashBenchmarkRuntime"; + +describe("Review Harness file content hash benchmark adapter", () => { + it("uses the packaged SHA-256 helper and xxHash64 engine with fixed Blob content", async () => { + const operations = await createFileContentHashBenchmarkOperations(); + const content = operations.createInput(4 * 1024); + + expect(operations.xxHashInitialisationMs).toBeGreaterThanOrEqual(0); + expect(await operations.sha256(content)).toMatch(/^sha256:/u); + expect(typeof (await operations.xxHash64(content))).toBe("bigint"); + expect(await operations.sha256(content)).toBe(await operations.sha256(content)); + expect(await operations.xxHash64(content)).toBe(await operations.xxHash64(content)); + }); +}); diff --git a/src/main.ts b/src/main.ts index 7ea2f730..72f2ff08 100644 --- a/src/main.ts +++ b/src/main.ts @@ -115,6 +115,7 @@ export default class ObsidianLiveSyncPlugin extends Plugin { events: services.context.events, API: services.API, database: services.database, + path: services.path, appLifecycle: services.appLifecycle, setting: services.setting, remote: services.remote, diff --git a/src/serviceFeatures/useReviewHarness.ts b/src/serviceFeatures/useReviewHarness.ts index cb56eb93..99e2cd5a 100644 --- a/src/serviceFeatures/useReviewHarness.ts +++ b/src/serviceFeatures/useReviewHarness.ts @@ -17,6 +17,8 @@ import { import type { CompatibilityReviewController } from "./compatibilityReview"; import { runReviewHarnessIdBenchmark } from "@/features/ReviewHarness/reviewHarnessIdBenchmark"; import { createIdBenchmarkOperations } from "@/features/ReviewHarness/reviewHarnessIdBenchmarkRuntime"; +import { runReviewHarnessFileContentHashBenchmark } from "@/features/ReviewHarness/reviewHarnessFileContentHashBenchmark"; +import { createFileContentHashBenchmarkOperations } from "@/features/ReviewHarness/reviewHarnessFileContentHashBenchmarkRuntime"; async function runVaultRoundTrip(plugin: ObsidianLiveSyncPlugin): Promise { const vault = plugin.app.vault; @@ -62,6 +64,11 @@ export function useReviewHarness( runVaultRoundTrip: () => runVaultRoundTrip(plugin), runIdBenchmark: async () => runReviewHarnessIdBenchmark(await createIdBenchmarkOperations(), activeWindow.performance), + runFileContentHashBenchmark: async () => + runReviewHarnessFileContentHashBenchmark( + await createFileContentHashBenchmarkOperations(activeWindow.performance), + activeWindow.performance + ), readContinuation: () => services.setting.getSmallConfig(REVIEW_HARNESS_STATE_KEY), writeContinuation: (value) => services.setting.setSmallConfig(REVIEW_HARNESS_STATE_KEY, value), deleteContinuation: () => services.setting.deleteSmallConfig(REVIEW_HARNESS_STATE_KEY), diff --git a/test/e2e-obsidian/README.md b/test/e2e-obsidian/README.md index 4030b4f8..c9f2878e 100644 --- a/test/e2e-obsidian/README.md +++ b/test/e2e-obsidian/README.md @@ -87,7 +87,7 @@ The wrapper accepts only maintained real-Obsidian scenario names; run it with `- `folder-batch` needs no remote service. It creates 24 notes and imports three notes with colons in their names into nested folders, reflects the database content into the existing files, then renames and deletes the parent through the Obsidian Vault API. It checks exact paths, descendant events, content, Chunks, deletion markers, provenance, and the absence of unexpected files. A note outside the parent must remain writable. The colon fixtures use the adapter to represent externally created files because Obsidian's Vault creation API rejects those names. The scenario also seeds a database-only colon-named note and verifies that Obsidian's refusal to create it preserves its Metadata without writing a differently named Vault file. It does not establish successful restoration of that absent note or behaviour on other operating systems. -`stale-file-restart` needs no remote service. It advances the local database while old Vault bytes remain, persists pending storage events, and restarts the same isolated Vault and profile. It checks that an unchanged file with exact provenance receives the newer database content without creating a revision, that unknown-origin content is preserved on a fresh independent branch, and that losing provenance and processing the file again does not duplicate or automatically merge that branch. A third file begins with no provenance while its bytes still match the current database revision; start-up must record that revision without creating a new one, and a later incoming revision must reflect without a conflict. The database advances and pending snapshot are controlled fixtures; start-up processing, persistence, file reflection, and conflict checking run in real Obsidian. The scenario does not simulate a mobile operating system suspending the application. +`stale-file-restart` needs no remote service. It advances the local database while old Vault bytes remain, persists pending storage events, and restarts the same isolated Vault and profile. It checks that an unchanged file whose recorded revision has been pruned receives the newer database content using persisted content evidence without creating a revision, that unknown-origin content is preserved on a fresh independent branch, and that losing provenance and processing the file again does not duplicate or automatically merge that branch. A third file begins with no provenance while its bytes still match the current database revision; start-up must record that revision without creating a new one, and a later incoming revision must reflect without a conflict. A fourth file has legacy revision-only provenance; start-up must verify its exact content and add a hash without creating a database revision or changing storage. The database advances and pending snapshot are controlled fixtures; start-up processing, persistence, file reflection, and conflict checking run in real Obsidian. The scenario does not simulate a mobile operating system suspending the application. The principal entry points are: diff --git a/test/e2e-obsidian/scripts/review-harness.ts b/test/e2e-obsidian/scripts/review-harness.ts index 3915f58a..49e22c67 100644 --- a/test/e2e-obsidian/scripts/review-harness.ts +++ b/test/e2e-obsidian/scripts/review-harness.ts @@ -204,6 +204,10 @@ async function runAutomaticScenarios(): Promise { .getByText("Passed:", { exact: false }) .waitFor({ state: "visible", timeout: uiTimeoutMs }); } + await harness + .locator('[data-testid="review-harness-result-file-content-hash-performance"]') + .getByText("Not run:", { exact: false }) + .waitFor({ state: "visible", timeout: uiTimeoutMs }); }); } @@ -252,6 +256,52 @@ async function runIdBenchmark(): Promise { }); } +async function runFileContentHashBenchmark(): Promise { + await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + const snapshotSettings = () => + page.evaluate(() => { + const plugin = (globalThis as ReviewHarnessTestGlobal).app?.plugins?.plugins["obsidian-livesync"] as { + core: { services: { setting: { currentSettings(): unknown } } }; + }; + return JSON.stringify(plugin.core.services.setting.currentSettings()); + }); + const before = await snapshotSettings(); + const harness = page.locator('[data-testid="review-harness"]'); + await harness + .locator('[data-testid="review-harness-run-file-content-hash-performance"]') + .click({ timeout: uiTimeoutMs }); + const result = harness.locator('[data-testid="review-harness-result-file-content-hash-performance"]'); + await result.getByText("Passed:", { exact: false }).waitFor({ state: "visible", timeout: uiTimeoutMs * 10 }); + const observations = await result.locator("li").allTextContents(); + for (const label of ["4 KiB", "64 KiB", "1 MiB", "50 MiB"]) { + for (const algorithm of ["SHA-256", "xxHash64"]) { + if ( + !observations.some( + (line) => line.startsWith(`${algorithm}, ${label}: median=`) && line.includes("; range=") + ) + ) { + throw new Error(`Missing file content hash timing and range: ${algorithm}, ${label}`); + } + } + } + for (const label of [ + "Input construction is excluded from timings.", + "xxHash64 engine initialisation:", + "Warm-up on 4 KiB", + "Calls run serially", + "JavaScript heap:", + ]) { + if (!observations.some((line) => line.includes(label))) { + throw new Error(`Missing file content hash measurement detail: ${label}`); + } + } + if ((await snapshotSettings()) !== before) throw new Error("The benchmark changed the live settings."); + await assertNoHorizontalOverflow(page, harness, { label: "file content hash benchmark results" }); + const timings = observations.filter((line) => line.startsWith("SHA-256,") || line.startsWith("xxHash64,")); + console.log(`File content hash benchmark observations:\n${timings.join("\n")}`); + }); +} + async function runVaultFixture(): Promise { await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { await page.locator('[data-testid="review-harness-run-vault-round-trip"]').click({ timeout: uiTimeoutMs }); @@ -409,6 +459,7 @@ async function verifyMobileHarness(): Promise { "review-harness-run-full", "review-harness-copy-report", "review-harness-run-id-generation-performance", + "review-harness-run-file-content-hash-performance", ]) { await assertLocatorHasMinimumTouchTarget(page, harness.locator(`[data-testid="${testId}"]`), { label: testId, @@ -482,6 +533,7 @@ async function main(): Promise { const resumedScreenshot = await restartAndResumeHarness(); await completeResumedCompatibilityStep(); await runIdBenchmark(); + await runFileContentHashBenchmark(); const report = await copyAndReadReport(); if (!report.includes("## Self-hosted LiveSync Review Harness report")) { throw new Error("The copied Review Harness report was not Markdown evidence."); @@ -491,6 +543,10 @@ async function main(): Promise { "; per ID=", "ID key derivation at save time:", "JavaScript heap:", + "SHA-256, 4 KiB: median=", + "xxHash64, 4 KiB: median=", + "SHA-256, 50 MiB: median=", + "xxHash64, 50 MiB: median=", ]) { if (!report.includes(expected)) throw new Error(`Missing copied benchmark observation: ${expected}`); } diff --git a/test/e2e-obsidian/scripts/stale-file-restart.ts b/test/e2e-obsidian/scripts/stale-file-restart.ts index 7a7f3d33..136f6969 100644 --- a/test/e2e-obsidian/scripts/stale-file-restart.ts +++ b/test/e2e-obsidian/scripts/stale-file-restart.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import { evalObsidianJson } from "../runner/cli.ts"; import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts"; import { @@ -11,6 +12,7 @@ import { createTemporaryVault } from "../runner/vault.ts"; const paths = ["stale-known.md", "stale-unknown.md"]; const recoveredPath = "stale-recovered.md"; +const legacyPath = "stale-legacy.md"; const oldContent = "# Note\nKeep\n\nTail\n\nFooter\n"; const newContent = oldContent.replace( "Footer\n", @@ -18,7 +20,15 @@ const newContent = oldContent.replace( ); type Branch = { rev: string; content: string; history: string[] }; -type FileState = { path: string; content: string; rev: string; branches: Branch[]; provenance: string | null }; +type FileState = { + path: string; + content: string; + rev: string; + branches: Branch[]; + provenance: string | null; + contentHash: string | null; + contentSize: number | null; +}; async function readState(cliBinary: string, env: NodeJS.ProcessEnv): Promise { return await evalObsidianJson( @@ -27,7 +37,7 @@ async function readState(cliBinary: string, env: NodeJS.ProcessEnv): Promise { await evalObsidianJson( cliBinary, `(async()=>{ - for(const path of ${JSON.stringify([...paths, recoveredPath])}) await app.vault.create(path,${JSON.stringify(oldContent)}); + for(const path of ${JSON.stringify([...paths, recoveredPath, legacyPath])}) await app.vault.create(path,${JSON.stringify(oldContent)}); return JSON.stringify(true); })()`, session.cliEnv ); - for (const path of [...paths, recoveredPath]) await waitForLocalDatabaseEntry(cliBinary, session.cliEnv, path); + for (const path of [...paths, recoveredPath, legacyPath]) + await waitForLocalDatabaseEntry(cliBinary, session.cliEnv, path); // Drain real Vault events before creating a persisted pending-event fixture. // The DB advances without reflecting it in the Vault, as on an offline device. - const fixture = await evalObsidianJson<{ current: string[]; original: string[]; recovered: string }>( + const fixture = await evalObsidianJson<{ + current: string[]; + original: string[]; + recovered: string; + legacy: string; + }>( cliBinary, `(async()=>{ const core=app.plugins.plugins['obsidian-livesync'].core; @@ -106,16 +123,32 @@ async function main(): Promise { const result=await core.localDatabase.putDBEntry({...meta,data,mtime:file.stat.mtime+60000, size:data.size,children:[]},false,meta._rev); if(!result?.ok) throw new Error('Could not advance '+path); - current.push(result.rev); original.push(meta._rev); - if(index===0) await store.set(path,{revision:meta._rev,observedStorageMtime:file.stat.mtime}); - else await store.delete(path); + let currentRev=result.rev; + if(index===0){ + const record=await store.get(path); + if(record?.revision!==meta._rev || !record.contentHash) throw new Error('Initial save did not record content evidence'); + // Advance beyond the local retention limit without reflecting the incoming bytes. + let raw=await core.localDatabase.getRaw(meta._id,{rev:currentRev}); + for(let update=0;update<100;update++){ + const next=await core.localDatabase.putRaw({...raw,mtime:raw.mtime+1}); + currentRev=next.rev; raw={...raw,_rev:currentRev,mtime:raw.mtime+1}; + } + let pruned=false; + try { await core.localDatabase.getRaw(meta._id,{rev:meta._rev}); } + catch(error) { if(error.status!==404) throw error; pruned=true; } + if(!pruned) throw new Error('The recorded revision was not pruned'); + } else await store.delete(path); + current.push(currentRev); original.push(meta._rev); snapshot.push({type:'CHANGED',key:'CHANGED-'+path,args:{file}}); } const recovered=${JSON.stringify(recoveredPath)}; const recoveredMeta=await core.localDatabase.getDBEntryMeta(recovered,{},true); await store.delete(recovered); + const legacy=${JSON.stringify(legacyPath)}; + const legacyMeta=await core.localDatabase.getDBEntryMeta(legacy,{},true); + await store.set(legacy,{revision:legacyMeta._rev}); await core.kvDB.set('storage-event-manager-snapshot',snapshot); - return JSON.stringify({current,original,recovered:recoveredMeta._rev}); + return JSON.stringify({current,original,recovered:recoveredMeta._rev,legacy:legacyMeta._rev}); })()`, session.cliEnv ); @@ -124,7 +157,7 @@ async function main(): Promise { session = await startObsidianLiveSyncSession({ binary, cliBinary, vault }); await waitForLiveSyncCoreReady(cliBinary, session.cliEnv); - const [known, unknown, recovered] = await readState(cliBinary, session.cliEnv); + const [known, unknown, recovered, legacy] = await readState(cliBinary, session.cliEnv); assertEqual(known.rev, fixture.current[0], "An unchanged stale file created a revision during restart."); assertEqual(known.branches.length, 1, "An unchanged stale file created a conflict."); assertEqual(known.content, newContent, "The newer DB content was not reflected after suppressing the save."); @@ -143,6 +176,15 @@ async function main(): Promise { assertEqual(recovered.content, oldContent, "Unchanged recovery modified the Vault file."); assertEqual(recovered.provenance, fixture.recovered, "Start-up did not recover missing file provenance."); + const oldHash = `sha256:${createHash("sha256").update(oldContent).digest("hex")}`; + const newHash = `sha256:${createHash("sha256").update(newContent).digest("hex")}`; + assertEqual(known.contentHash, newHash, "Reflection did not record the incoming bytes."); + assertEqual(recovered.contentHash, oldHash, "Recovered provenance lacks verified content evidence."); + assertEqual(legacy.rev, fixture.legacy, "Legacy upgrade created a database revision."); + assertEqual(legacy.content, oldContent, "Legacy upgrade changed storage."); + assertEqual(legacy.contentHash, oldHash, "Legacy provenance was not upgraded after exact verification."); + assertEqual(legacy.contentSize, Buffer.byteLength(oldContent), "Legacy provenance has the wrong byte length."); + const receivedRevision = await evalObsidianJson( cliBinary, `(async()=>{ @@ -204,7 +246,7 @@ async function main(): Promise { assertEqual(resolved.branches.length, 1, "The explicit newer-file option did not resolve the conflict."); assertEqual(resolved.content, newContent, "The newer-file option did not reflect the newer DB version."); console.log( - "Stale-file restart: missing unchanged provenance recovered; later update reflected; unknown content preserved; explicit newer-file resolution retained." + "Stale-file restart: pruned revision recognised from persisted content evidence; legacy provenance upgraded; missing unchanged provenance recovered; later update reflected; unknown content preserved; explicit newer-file resolution retained." ); } finally { if (session) await session.app.stop(); diff --git a/updates.md b/updates.md index 31d529ec..c8dc71d6 100644 --- a/updates.md +++ b/updates.md @@ -12,6 +12,15 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi ## Unreleased +### Fixed + +- Unchanged files can receive synchronised updates even after the local database has discarded their recorded revision, avoiding unnecessary conflicts. (#1207) + - Saved content evidence survives revision-history pruning. Genuine local edits remain protected, and older records are upgraded only after their content is verified. + +### Maintenance + +- The Review Harness can compare SHA-256 and xxHash64 file-content hashing on the current device, with fixed inputs from 4 KiB to 50 MiB and separate initialisation and warm-up measurements. + ## 1.0.37 16th October, 2026 From bf8e899bf797fe21f14f8f0d11e9226385148d02 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Sat, 17 Oct 2026 13:49:11 +0000 Subject: [PATCH 2/2] Cover content evidence after conflict-base compaction --- ...ileHandler.multidevice.integration.spec.ts | 43 +++++++++++++++++-- test/README.md | 6 ++- 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/src/serviceModules/FileHandler.multidevice.integration.spec.ts b/src/serviceModules/FileHandler.multidevice.integration.spec.ts index af82d242..09fd9367 100644 --- a/src/serviceModules/FileHandler.multidevice.integration.spec.ts +++ b/src/serviceModules/FileHandler.multidevice.integration.spec.ts @@ -13,6 +13,7 @@ import { type ServiceDatabaseFileAccessDependencies, } from "@vrtmrz/livesync-commonlib/compat/serviceModules/ServiceDatabaseFileAccessBase"; import type { ServiceFileHandlerDependencies } from "@vrtmrz/livesync-commonlib/compat/serviceModules/ServiceFileHandlerBase"; +import type { FileReflectionProvenanceRecord } from "@vrtmrz/livesync-commonlib/compat/interfaces/FileReflectionProvenance"; import { ServiceFileHandler } from "./FileHandler"; import { createConflictResolutionOperations, @@ -38,7 +39,7 @@ function makeFile(body: string, mtime = oldTime): UXFileInfo { async function makeDevice(name: string) { const db = new PouchDB(name, { adapter: "memory" }); - const reflection = new Map(); + const reflection = new Map(); let storage = makeFile(old); const settings = { ...DEFAULT_SETTINGS, useOnlyLocalChunk: true, writeDocumentsIfConflicted: false }; const setting = { currentSettings: () => settings }; @@ -94,7 +95,7 @@ async function makeDevice(name: string) { conflict, fileReflectionProvenance: { get: (value: FilePathWithPrefix) => Promise.resolve(reflection.get(value)), - set: (value: FilePathWithPrefix, record: { revision: string }) => { + set: (value: FilePathWithPrefix, record: FileReflectionProvenanceRecord) => { reflection.set(value, record); return Promise.resolve(); }, @@ -287,7 +288,32 @@ describe("file provenance across multiple devices and real CouchDB", () => { } }, 60_000); - it.each(["missing record", "compacted base"] as const)( + it("reflects the selected content after compacting an unchanged losing branch", async () => { + const f = await conflictedDevices(4); + const { keep, content } = await resolveAndReplicate(f); + const index = f.revisions.findIndex((rev, i) => i > 0 && rev !== keep); + const device = f.devices[index]; + const record = device.reflection.get(path); + expect(record?.revision).toBe(f.revisions[index]); + expect(record?.contentHash).toMatch(/^sha256:[0-9a-f]{64}$/u); + expect(record?.contentSize).toBe(device.getStorage().body.size); + await device.db.compact(); + await expect(device.db.get(path, { rev: f.revisions[index] })).rejects.toMatchObject({ status: 404 }); + + const before = (await device.db.info()).update_seq; + await expect(device.handler.storeFileToDB(path)).resolves.toBe(true); + expect((await device.db.info()).update_seq).toBe(before); + expect(await device.getStorage().body.text()).toBe(content); + expect(device.reflection.get(path)?.revision).toBe(keep); + await device.db.replicate.to(f.remote); + for (const peer of f.devices) { + await peer.db.replicate.from(f.remote); + expect((await leaves(peer.db)).map((doc) => doc._rev)).toEqual([keep]); + expect(await revisionContent(peer, keep)).toBe(content); + } + }, 60_000); + + it.each(["missing record", "compacted legacy base", "compacted hashed base with local edits"] as const)( "preserves uncertain storage as one independent conflict with four devices: %s", async (reason) => { const f = await conflictedDevices(4); @@ -301,6 +327,17 @@ describe("file provenance across multiple devices and real CouchDB", () => { } else { await device.db.compact(); await expect(device.db.get(path, { rev: f.revisions[index] })).rejects.toMatchObject({ status: 404 }); + if (reason === "compacted legacy base") { + // Revision-only records still require protection when their recorded body is unavailable. + const { revision, observedStorageMtime } = device.reflection.get(path)!; + device.reflection.set(path, { revision, observedStorageMtime }); + } else { + expect(device.reflection.get(path)?.contentHash).toMatch(/^sha256:/u); + const original = device.getStorage(); + const edited = (await original.body.text()).replace("Edited", "EDITED"); + device.setStorage(makeFile(edited, original.stat.mtime)); + expect(device.getStorage().body.size).toBe(original.body.size); + } } const uncertainContent = await device.getStorage().body.text(); await expect(device.handler.storeFileToDB(path)).resolves.toBe(true); diff --git a/test/README.md b/test/README.md index b1c0cfac..7baaaf99 100644 --- a/test/README.md +++ b/test/README.md @@ -98,7 +98,7 @@ npm run test:docker-couchdb:start NODE_OPTIONS=--max-old-space-size=3072 npm run test:integration -- src/serviceModules/FileHandler.multidevice.integration.spec.ts --maxWorkers=1 ``` -The expected result is five passing tests. Each case creates a uniquely named remote database and removes it during teardown. Stop the fixture after the run, including when the test command fails: +The expected result is seven passing tests. Each case creates a uniquely named remote database and removes it during teardown. Stop the fixture after the run, including when the test command fails: ```bash npm run test:docker-couchdb:stop @@ -116,7 +116,9 @@ Each simulated device owns a separate real PouchDB database, `LiveSyncLocalDB` m | Four editing devices | Repeat the same sequence with four independently edited branches. | All four contents are initially preserved, and the same unchanged-save and convergence guarantees hold. | | A genuine edit on a losing device | After three-way resolution reaches its DB, a device adds content to its still-unreflected losing file, then saves and replicates it. | The new revision extends that device's recorded branch. The selected result and the new edit remain readable on every replica. | | Missing provenance | After four-way resolution, remove a losing device's provenance record and set its file to the original historical ancestor's content. Save, remove provenance again, and repeat the save. | The file becomes one fresh independent root, distinct from the historical root. Both contents remain readable after replication, and repeated saves create no duplicates. CLI `resolve` can select the independent root and propagate its resolution. | -| Compacted base | After four-way resolution, compact a losing device's local DB and confirm that its recorded revision body is unavailable. Save the remaining file, then repeat after removing provenance. | The file is preserved as one independent conflict rather than discarded. Both contents remain readable on every replica, repeated saves create no duplicates, and CLI `resolve` can select the independent root and propagate its resolution. | +| Compacted base with verified unchanged content | After four-way resolution, retain a losing device's content hash, compact its local DB, and confirm that its recorded revision body is unavailable. Save the unchanged file. | The selected content is reflected without adding a database revision or conflict, and all replicas retain only the selected branch. | +| Compacted base with legacy provenance | After four-way resolution, retain only the losing device's revision and observed modification time, compact its local DB, and confirm that the recorded revision body is unavailable. Save the remaining file, then repeat after removing provenance. | The file is preserved as one independent conflict. Both contents remain readable on every replica, repeated saves create no duplicates, and CLI `resolve` can select the independent root and propagate its resolution. | +| Compacted base with a genuine edit | After four-way resolution and local compaction, retain the losing device's content hash but edit its file without changing its byte length or modification time. | The changed content is preserved as one independent conflict, remains readable on every replica, and can be selected through CLI `resolve`. Repeated saves after provenance loss create no duplicates. | ### Coverage boundaries