Test Garbage Collection V3 against CouchDB

This commit is contained in:
vorotamoroz
2026-07-25 03:48:51 +00:00
parent c24fc1dc82
commit 0d68b6530f
2 changed files with 260 additions and 1 deletions
+3 -1
View File
@@ -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.
@@ -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<string, never>;
};
type FixtureDocument = PouchDB.Core.PutDocument<FixtureContent> & {
_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<FixtureContent>): LiveSyncLocalDB {
const subject = Object.create(LiveSyncLocalDB.prototype) as LiveSyncLocalDB;
Object.assign(subject, {
localDatabase: database as unknown as PouchDB.Database<EntryDoc>,
});
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<FixtureContent>(`${databaseName}-source`, { adapter: "memory" });
const replica = new PouchDB<FixtureContent>(`${databaseName}-replica`, { adapter: "memory" });
const remote = new PouchDB<FixtureContent>(
`${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);
});