Protect conflict chunks during garbage collection

This commit is contained in:
vorotamoroz
2026-07-24 17:09:22 +00:00
parent 07cba4ce83
commit 20fb027055
9 changed files with 220 additions and 36 deletions
@@ -24,7 +24,12 @@ vi.mock("@/common/events", () => ({
onEvent: vi.fn(),
},
}));
import { DEFAULT_SETTINGS, REMOTE_COUCHDB, REMOTE_MINIO } from "@vrtmrz/livesync-commonlib/compat/common/types";
import {
DEFAULT_SETTINGS,
REMOTE_COUCHDB,
REMOTE_MINIO,
REMOTE_P2P,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { LocalDatabaseMaintenance } from "./CmdLocalDatabaseMainte";
import { ensureLocalDatabaseMaintenancePrerequisites } from "./maintenancePrerequisites";
@@ -87,6 +92,9 @@ describe("LocalDatabaseMaintenance prerequisites", () => {
settings.useEdgeCaseMode = true;
expect(garbageCollect?.checkCallback?.(true)).toBe(true);
settings.remoteType = REMOTE_P2P;
expect(garbageCollect?.checkCallback?.(true)).toBe(false);
settings.remoteType = REMOTE_MINIO;
expect(garbageCollect?.checkCallback?.(true)).toBe(false);
});
@@ -176,4 +184,129 @@ describe("LocalDatabaseMaintenance prerequisites", () => {
expect(askSelectStringDialogue).not.toHaveBeenCalled();
expect(applyPartial).not.toHaveBeenCalled();
});
it("describes the current chunk-recreation action without promising historical recovery", async () => {
const maintenance = Object.create(LocalDatabaseMaintenance.prototype) as LocalDatabaseMaintenance;
const askSelectStringDialogue = vi.fn().mockResolvedValue("Cancel");
Object.assign(maintenance, {
core: {
confirm: {
askSelectStringDialogue,
},
},
_log: vi.fn(),
});
vi.spyOn(maintenance, "ensureAvailable").mockResolvedValue(true);
vi.spyOn(maintenance, "trackChanges").mockResolvedValue(undefined);
await maintenance.performGC();
const message = vi.mocked(askSelectStringDialogue).mock.calls[0]?.[0] as string;
expect(message).toContain("Hatch -> Recreate chunks for current Vault files");
expect(message).toContain("only from files currently present in the Vault");
expect(message).not.toContain("Recreate missing chunks for all files");
});
});
describe("LocalDatabaseMaintenance Garbage Collection V3", () => {
it("keeps chunks referenced by a live conflict revision and deletes only unreachable chunks", async () => {
const maintenance = Object.create(LocalDatabaseMaintenance.prototype) as LocalDatabaseMaintenance;
const pushModes: string[] = [];
const deletedChunks: Array<{ _id: string; _rev?: string; _deleted?: boolean }> = [];
const allChunks = vi.fn(async () => ({
used: new Set(["h:winner", "h:conflict"]),
existing: new Map([
["h:winner", { _id: "h:winner", _rev: "1-winner", type: "leaf", data: "winner" }],
["h:conflict", { _id: "h:conflict", _rev: "1-conflict", type: "leaf", data: "conflict" }],
["h:obsolete", { _id: "h:obsolete", _rev: "1-obsolete", type: "leaf", data: "obsolete" }],
]),
}));
const rawDocuments = new Map<string, object>([
[
"note.md",
{
_id: "note.md",
_rev: "2-winner",
_conflicts: ["2-conflict"],
type: "plain",
children: ["h:winner"],
},
],
["h:winner", { _id: "h:winner", _rev: "1-winner", type: "leaf", data: "winner" }],
["h:conflict", { _id: "h:conflict", _rev: "1-conflict", type: "leaf", data: "conflict" }],
["h:obsolete", { _id: "h:obsolete", _rev: "1-obsolete", type: "leaf", data: "obsolete" }],
]);
const findEntryNames = vi.fn(async function* () {
yield* rawDocuments.keys();
});
const getRaw = vi.fn(async (id: string) => rawDocuments.get(id));
const localDatabase = {
allChunks,
localDatabase: {
info: vi.fn(async () => ({ doc_count: rawDocuments.size })),
bulkDocs: vi.fn(async (docs: Array<{ _id: string; _rev?: string; _deleted?: boolean }>) => {
deletedChunks.push(...docs);
return docs.map(({ _id }) => ({ ok: true, id: _id, rev: "2-deleted" }));
}),
},
findEntryNames,
getRaw,
};
const replicator = {
openOneShotReplication: vi.fn(
async (
_settings: typeof DEFAULT_SETTINGS,
_showResult: boolean,
_ignoreCleanLock: boolean,
mode: string
) => {
pushModes.push(mode);
return true;
}
),
getConnectedDeviceList: vi.fn(async () => ({
accepted_nodes: ["device-a"],
node_info: {
"device-a": {
progress: "10-local",
device_name: "Device A",
app_version: "1.12.7",
plugin_version: "1.0.0-beta.0",
},
},
})),
};
Object.assign(maintenance, {
core: {
settings: {
...DEFAULT_SETTINGS,
remoteType: REMOTE_COUCHDB,
},
replicator,
confirm: {
askSelectStringDialogue: vi.fn(async () => "Proceed Garbage Collection"),
},
},
localDatabase,
_notice: vi.fn(),
});
vi.spyOn(maintenance, "ensureAvailable").mockResolvedValue(true);
vi.spyOn(maintenance, "compactDatabase").mockResolvedValue(undefined);
vi.spyOn(maintenance, "clearHash").mockImplementation(() => undefined);
await maintenance.gcv3();
expect(allChunks).toHaveBeenCalledOnce();
expect(findEntryNames).not.toHaveBeenCalled();
expect(getRaw).not.toHaveBeenCalled();
expect(deletedChunks).toEqual([
{
_id: "h:obsolete",
_rev: "1-obsolete",
_deleted: true,
},
]);
expect(pushModes).toEqual(["sync", "pushOnly"]);
expect(maintenance.compactDatabase).toHaveBeenCalledOnce();
});
});