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