Preserve CLI database continuity after reconstruction

This commit is contained in:
vorotamoroz committed 2026-11-01 05:06:42 +00:00
1 parent 0e458e5e20
commit d50ee9a83e
21 files changed
+1585 -60

No files matched your search

+197 -1
View File
@@ -78,12 +78,20 @@ describe("CLI database preparation", () => {
await rm(directory, { recursive: true, force: true });
});
async function start(command: CLICommand, runner: CliCommandRunner, exitCode = 1, force = false) {
async function start(
command: CLICommand,
runner: CliCommandRunner,
exitCode = 1,
force = false,
writeSettings = false
) {
process.exitCode = undefined;
process.argv = [
"node",
"livesync-cli",
directory,
...(force ? ["--force"] : []),
...(writeSettings ? ["--write-settings"] : []),
"--vault",
vaultPath,
"--settings",
@@ -386,6 +394,194 @@ describe("CLI database preparation", () => {
expect(await readFile(join(vaultPath, "notes/local.md"), "utf-8")).toBe("local content");
});
it.each(["fetch", "rebuild"] as const)(
"persists the selected database after %s without other command settings",
async (command) => {
const before = JSON.parse(await readFile(settingsPath, "utf-8"));
const runner = vi.fn<CliCommandRunner>(async (_options, { core }) => {
await core.services.setting.applyPartial({
additionalSuffixOfDatabaseName: "headless-app",
customChunkSize: before.customChunkSize + 10,
});
return true;
});
await start(command, runner, 0, true);
const after = JSON.parse(await readFile(settingsPath, "utf-8"));
expect(after.additionalSuffixOfDatabaseName).toBe("headless-app");
expect(after.customChunkSize).toBe(before.customChunkSize);
}
);
it.each(["suffix", "vault"] as const)("isolates persistent state by selected %s", async (selection) => {
await start("ls", async (_options, { core }) => {
await core.services.keyValueDB.kvDB.set("scope-probe", "original");
await core.services.config.setValue("scope-probe", "original");
return false;
});
if (selection === "suffix") {
const settings = JSON.parse(await readFile(settingsPath, "utf-8"));
settings.additionalSuffixOfDatabaseName = "other";
await writeFile(settingsPath, JSON.stringify(settings));
} else {
vaultPath = join(directory, "other-vault");
await mkdir(vaultPath);
}
const observed: unknown[] = [];
await start("ls", async (_options, { core }) => {
observed.push(await core.services.keyValueDB.kvDB.get("scope-probe"));
observed.push(await core.services.config.getValue("scope-probe"));
return false;
});
expect(observed).toEqual([undefined, null]);
});
it.each(["sync", "daemon", "mirror", "put"] as const)(
"blocks %s after failed reconstruction before command preparation",
async (command) => {
await start("fetch", async () => false, 1, true);
const runner = vi.fn<CliCommandRunner>(async () => false);
const ready = vi.spyOn(ControlService.prototype, "onReady");
await start(command, runner);
expect(runner).not.toHaveBeenCalled();
expect(ready).not.toHaveBeenCalled();
}
);
it("retains legacy queued work and blocks a scanner until explicit reconstruction succeeds", async () => {
const legacy = join(directory, ".livesync/runtime/keyvalue-db.json");
const legacyContents = JSON.stringify({
"queued-files": ["notes/unique.md"],
fileStatusMap: { "notes/local.md": 1 },
});
await mkdir(join(directory, ".livesync/runtime"), { recursive: true });
await writeFile(legacy, legacyContents);
const runner = vi.fn<CliCommandRunner>(async () => false);
const scan = vi.spyOn(ServiceFileAccessCLI.prototype, "getFiles");
await start("mirror", runner);
expect(runner).not.toHaveBeenCalled();
expect(scan).not.toHaveBeenCalled();
expect(await readFile(legacy, "utf-8")).toBe(legacyContents);
await start("fetch", async () => true, 0, true);
const later = vi.fn<CliCommandRunner>(async () => false);
await start("sync", later);
expect(later).toHaveBeenCalledOnce();
expect(await readFile(legacy, "utf-8")).toBe(legacyContents);
});
it("permits normal commands after reconstruction while preserving malformed legacy JSON", async () => {
const legacy = join(directory, ".livesync/runtime/keyvalue-db.json");
await mkdir(join(directory, ".livesync/runtime"), { recursive: true });
await writeFile(legacy, '{"unfinished":');
await start("fetch", async () => true, 0, true);
const runner = vi.fn<CliCommandRunner>(async () => false);
await start("sync", runner);
expect(runner).toHaveBeenCalledOnce();
expect(await readFile(legacy, "utf-8")).toBe('{"unfinished":');
});
it.each([
["kv", '{"unfinished":', "cannot be parsed"],
["snapshot", "[", "cannot be parsed"],
["kv", '{"queued-files":["unique.md"]}', "owner is unknown"],
["snapshot", '[{"path":"unique.md"}]', "owner is unknown"],
])("diagnoses unowned %s (%s) before readiness and ignores it after recovery", async (kind, contents, reason) => {
const legacy =
kind === "kv"
? join(directory, ".livesync/runtime/keyvalue-db.json")
: join(vaultPath, ".livesync-snapshot.json");
await mkdir(join(directory, ".livesync/runtime"), { recursive: true });
await writeFile(legacy, contents);
const messages = () => standardIo.writeStderr.mock.calls.flat().join("");
const reader = vi.fn<CliCommandRunner>(async () => false);
await start("ls", reader);
expect(reader).toHaveBeenCalledOnce();
expect(messages()).toContain(reason);
expect(messages()).toContain(legacy);
expect(messages()).toContain("inspection may continue");
expect(messages().split("[Legacy state]")).toHaveLength(2);
expect(standardIo.writeStdout.mock.calls.flat().join("")).not.toContain("[Legacy state]");
standardIo.writeStderr.mockClear();
const writer = vi.fn<CliCommandRunner>(async () => false);
const ready = vi.spyOn(ControlService.prototype, "onReady");
await start("mirror", writer);
expect(writer).not.toHaveBeenCalled();
expect(ready).not.toHaveBeenCalled();
expect(messages()).toContain("stopped before scanning");
expect(messages()).toContain(reason);
expect(messages().split("[Legacy state]")).toHaveLength(2);
ready.mockRestore();
await start("fetch", async () => true, 0, true);
standardIo.writeStderr.mockClear();
const later = vi.fn<CliCommandRunner>(async () => false);
await start("sync", later);
expect(later).toHaveBeenCalledOnce();
expect(messages()).not.toContain("[Legacy state]");
expect(await readFile(legacy, "utf-8")).toBe(contents);
});
it("blocks read commands which request settings writes while reconstruction is interrupted", async () => {
await start("fetch", async () => false, 1, true);
const runner = vi.fn<CliCommandRunner>(async () => false);
const load = vi.spyOn(ControlService.prototype, "onLoad");
await start("ls", runner, 1, false, true);
expect(runner).not.toHaveBeenCalled();
expect(load).not.toHaveBeenCalled();
});
it("invalidates every Vault scope for the reset database and retains other database state", async () => {
const originalVault = vaultPath;
const otherVault = join(directory, "other-vault");
await mkdir(otherVault);
const select = async (suffix: string, selectedVault: string) => {
vaultPath = selectedVault;
const settings = JSON.parse(await readFile(settingsPath, "utf-8"));
settings.additionalSuffixOfDatabaseName = suffix;
await writeFile(settingsPath, JSON.stringify(settings));
};
for (const [suffix, selectedVault] of [
["", originalVault],
["", otherVault],
["other", originalVault],
]) {
await select(suffix, selectedVault);
await start("ls", async (_options, { core }) => {
await core.services.keyValueDB.kvDB.set("scope-probe", "stored");
return false;
});
}
await select("", originalVault);
let reset: boolean | undefined;
await start("ls", async (_options, { core }) => {
reset = await core.services.database.resetDatabase();
return false;
});
expect(reset).toBe(true);
const observed: unknown[] = [];
for (const [suffix, selectedVault] of [
["", originalVault],
["", otherVault],
["other", originalVault],
]) {
await select(suffix, selectedVault);
await start("ls", async (_options, { core }) => {
observed.push(await core.services.keyValueDB.kvDB.get("scope-probe"));
return false;
});
}
expect(observed).toEqual([undefined, undefined, "stored"]);
});
it("does not import vault files for standalone database commands", async () => {
const storedPaths: string[] = [];
const runner = vi.fn<CliCommandRunner>(async (_options, { core }) => {