diff --git a/docs/specs_garbage_collection.md b/docs/specs_garbage_collection.md index 40f00c82..8a75f975 100644 --- a/docs/specs_garbage_collection.md +++ b/docs/specs_garbage_collection.md @@ -60,4 +60,6 @@ Commonlib tests use real in-memory PouchDB revision trees to verify: - propagation of chunk deletion to another PouchDB database; and - recreation and propagation when the same content is written again. -Self-hosted LiveSync tests verify that Garbage Collection V3 uses Commonlib's revision-aware result, deletes only unreachable chunks, performs the initial bidirectional and final push-only replications in order, and requests remote compaction. CouchDB's own compaction implementation remains an external database boundary. +Self-hosted LiveSync unit tests verify that Garbage Collection V3 uses Commonlib's revision-aware result, deletes only unreachable chunks, performs the initial bidirectional and final push-only replications in order, and requests remote compaction. + +A disposable real-CouchDB integration test verifies logical deletion on the server, retention of shared and conflict chunks, compaction completion, replication after collection, and recreation of a content-addressed chunk. CouchDB's choice and timing of physical byte reclamation remain an external database boundary, so the test does not assert a particular reduction in database size. diff --git a/src/features/LocalDatabaseMainte/CmdLocalDatabaseMainte.integration.spec.ts b/src/features/LocalDatabaseMainte/CmdLocalDatabaseMainte.integration.spec.ts new file mode 100644 index 00000000..84f94ed2 --- /dev/null +++ b/src/features/LocalDatabaseMainte/CmdLocalDatabaseMainte.integration.spec.ts @@ -0,0 +1,257 @@ +import { describe, expect, it, vi } from "vitest"; +import PouchDB from "pouchdb-core"; +import HttpPouch from "pouchdb-adapter-http"; +import MemoryAdapter from "pouchdb-adapter-memory"; +import replication from "pouchdb-replication"; + +vi.mock("@/features/LiveSyncCommands", () => ({ + LiveSyncCommands: class LiveSyncCommands { + core!: { settings: unknown }; + get settings() { + return this.core.settings; + } + }, +})); +vi.mock("@/common/events", () => ({ + EVENT_ANALYSE_DB_USAGE: "analyse", + EVENT_REQUEST_PERFORM_GC_V3: "gc", + eventHub: { + onEvent: vi.fn(), + }, +})); + +import { + DEFAULT_SETTINGS, + REMOTE_COUCHDB, + type DocumentID, + type EntryDoc, +} from "@vrtmrz/livesync-commonlib/compat/common/types"; +import { LiveSyncLocalDB } from "@vrtmrz/livesync-commonlib/compat/pouchdb/LiveSyncLocalDB"; +import { LocalDatabaseMaintenance } from "./CmdLocalDatabaseMainte"; + +PouchDB.plugin(HttpPouch).plugin(MemoryAdapter).plugin(replication); + +type FixtureContent = { + type: "leaf" | "plain"; + data?: string; + path?: string; + children?: string[]; + ctime?: number; + mtime?: number; + size?: number; + eden?: Record; +}; + +type FixtureDocument = PouchDB.Core.PutDocument & { + _id: DocumentID; + _revisions?: { + start: number; + ids: string[]; + }; +}; + +function chunk(id: string, data = id): FixtureDocument { + return { + _id: id as DocumentID, + type: "leaf", + data, + }; +} + +function revision(id: string, rev: string, history: string[], children: string[]): FixtureDocument { + return { + _id: id as DocumentID, + _rev: rev, + _revisions: { + start: Number(rev.split("-")[0]), + ids: history, + }, + type: "plain", + path: id, + children, + ctime: 1, + mtime: 1, + size: children.length, + eden: {}, + } as unknown as FixtureDocument; +} + +function liveSyncDatabaseFor(database: PouchDB.Database): LiveSyncLocalDB { + const subject = Object.create(LiveSyncLocalDB.prototype) as LiveSyncLocalDB; + Object.assign(subject, { + localDatabase: database as unknown as PouchDB.Database, + }); + return subject; +} + +function requiredEnvironment(name: "hostname" | "username" | "password"): string { + const value = process.env[name]; + if (!value) { + throw new Error(`Missing required integration-test environment variable: ${name}`); + } + return value; +} + +describe("LocalDatabaseMaintenance Garbage Collection V3 with CouchDB", () => { + it("propagates collection safely, completes compaction, and permits content-addressed chunk recreation", async () => { + const databaseName = `livesync-gcv3-${crypto.randomUUID()}`; + const local = new PouchDB(`${databaseName}-source`, { adapter: "memory" }); + const replica = new PouchDB(`${databaseName}-replica`, { adapter: "memory" }); + const remote = new PouchDB( + `${requiredEnvironment("hostname").replace(/\/+$/u, "")}/${databaseName}`, + { + adapter: "http", + auth: { + username: requiredEnvironment("username"), + password: requiredEnvironment("password"), + }, + } + ); + + try { + await remote.info(); + await local.bulkDocs([ + chunk("h:obsolete"), + chunk("h:current"), + chunk("h:shared"), + chunk("h:base"), + chunk("h:left"), + chunk("h:right"), + ]); + + const firstRevision = revision("first.md", "1-first", ["first"], ["h:obsolete"]); + delete firstRevision._rev; + delete firstRevision._revisions; + await local.put(firstRevision); + await local.put({ + ...(await local.get("first.md")), + children: ["h:current"], + }); + + for (const id of ["second.md", "third.md"]) { + const sharedRevision = revision(id, `1-${id}`, [id], ["h:shared"]); + delete sharedRevision._rev; + delete sharedRevision._revisions; + await local.put(sharedRevision); + } + + await local.bulkDocs( + [ + revision("conflicted.md", "1-base", ["base"], ["h:base"]), + revision("conflicted.md", "2-left", ["left", "base"], ["h:left"]), + revision("conflicted.md", "2-right", ["right", "base"], ["h:right"]), + ], + { new_edits: false } + ); + + const liveSyncDatabase = liveSyncDatabaseFor(local); + const replicationModes: string[] = []; + const replicator = { + openOneShotReplication: vi.fn( + async ( + _settings: typeof DEFAULT_SETTINGS, + _showResult: boolean, + _ignoreCleanLock: boolean, + mode: string + ) => { + replicationModes.push(mode); + if (mode === "sync") { + await local.sync(remote); + } else if (mode === "pushOnly") { + await local.replicate.to(remote); + } else { + throw new Error(`Unexpected replication mode: ${mode}`); + } + return true; + } + ), + getConnectedDeviceList: vi.fn(() => + Promise.resolve({ + accepted_nodes: ["integration-device"], + node_info: { + "integration-device": { + progress: "10-local", + device_name: "Integration device", + app_version: "1.12.7", + plugin_version: "1.0.0-beta.0", + }, + }, + }) + ), + connectRemoteCouchDBWithSetting: vi.fn(() => Promise.resolve({ db: remote })), + }; + const notice = vi.fn(); + const maintenance = Object.create(LocalDatabaseMaintenance.prototype) as LocalDatabaseMaintenance; + Object.assign(maintenance, { + core: { + settings: { + ...DEFAULT_SETTINGS, + remoteType: REMOTE_COUCHDB, + }, + replicator, + confirm: { + askSelectStringDialogue: vi.fn(() => Promise.resolve("Proceed Garbage Collection")), + }, + }, + localDatabase: liveSyncDatabase, + _notice: notice, + }); + vi.spyOn(maintenance, "ensureAvailable").mockResolvedValue(true); + const clearHash = vi.spyOn(maintenance, "clearHash").mockImplementation(() => undefined); + + await maintenance.gcv3(); + + expect(replicationModes).toEqual(["sync", "pushOnly"]); + expect(clearHash).toHaveBeenCalledOnce(); + expect(notice).toHaveBeenCalledWith("Compaction on remote database completed successfully.", "gc-compact"); + expect(notice).not.toHaveBeenCalledWith("Compaction on remote database timed out.", "gc-compact"); + expect(notice).not.toHaveBeenCalledWith("Compaction on remote database failed.", "gc-compact"); + + const obsoleteRow = (await remote.allDocs({ keys: ["h:obsolete"] })).rows[0]; + expect(obsoleteRow).toMatchObject({ + id: "h:obsolete", + value: { + deleted: true, + }, + }); + + for (const retainedChunk of ["h:current", "h:shared", "h:base", "h:left", "h:right"]) { + await expect(remote.get(retainedChunk)).resolves.toMatchObject({ + _id: retainedChunk, + type: "leaf", + }); + } + await expect(remote.get("second.md")).resolves.toMatchObject({ children: ["h:shared"] }); + await expect(remote.get("third.md")).resolves.toMatchObject({ children: ["h:shared"] }); + await expect(remote.get("conflicted.md", { conflicts: true })).resolves.toMatchObject({ + _conflicts: [expect.any(String)], + }); + + await remote.replicate.to(replica); + await expect(replica.get("h:obsolete")).rejects.toMatchObject({ status: 404 }); + for (const retainedChunk of ["h:current", "h:shared", "h:base", "h:left", "h:right"]) { + await expect(replica.get(retainedChunk)).resolves.toMatchObject({ + _id: retainedChunk, + type: "leaf", + }); + } + + await local.put(chunk("h:obsolete", "recreated")); + await local.replicate.to(remote); + await remote.replicate.to(replica); + + await expect(remote.get("h:obsolete")).resolves.toMatchObject({ + _id: "h:obsolete", + type: "leaf", + data: "recreated", + }); + await expect(replica.get("h:obsolete")).resolves.toMatchObject({ + _id: "h:obsolete", + type: "leaf", + data: "recreated", + }); + } finally { + await Promise.all([local.destroy(), replica.destroy(), remote.destroy()]); + } + }, 30_000); +});