mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-07-25 14:02:58 +00:00
Test Garbage Collection V3 against CouchDB
This commit is contained in:
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user