feat(FN-2895): add FTS5 corruption recovery path

- Add Database APIs to rebuild the tasks_fts index and detect FTS5 corruption signatures
- Retry task upserts once after rebuilding FTS5 when corruption errors are encountered
- Extend TaskStore health checks to include FTS5 integrity verification
- Add regression tests for rebuild/integrity behavior and upsert recovery, plus a patch changeset for @runfusion/fusion
This commit is contained in:
Fusion
2026-04-28 16:51:04 -07:00
committed by gsxdsm
parent 64f662624b
commit 29be0d22ad
5 changed files with 248 additions and 4 deletions

View File

@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { Database, createDatabase, toJson, toJsonNullable, fromJson, normalizeTaskComments } from "../db.js";
import { DEFAULT_PROJECT_SETTINGS } from "../types.js";
import { mkdtempSync, existsSync } from "node:fs";
@@ -1445,6 +1445,88 @@ describe("FTS5 full-text search", () => {
expect(ftsRow).toBeDefined();
expect(ftsRow.comments).toContain("xylophone");
});
it("rebuildFts5Index recreates and repopulates the FTS table", () => {
db.prepare(`
INSERT INTO tasks (id, title, description, "column", createdAt, updatedAt)
VALUES ('FN-FTS-REBUILD', 'Rebuild title', 'Rebuild description', 'todo', '2025-01-01T00:00:00.000Z', '2025-01-01T00:00:00.000Z')
`).run();
db.exec("DROP TRIGGER IF EXISTS tasks_fts_ai");
db.exec("DROP TRIGGER IF EXISTS tasks_fts_au");
db.exec("DROP TRIGGER IF EXISTS tasks_fts_ad");
db.exec("DROP TABLE IF EXISTS tasks_fts");
db.exec(`
CREATE VIRTUAL TABLE tasks_fts USING fts5(
id,
title,
description,
comments,
content='tasks',
content_rowid='rowid'
)
`);
const missingTrigger = db.prepare(
"SELECT name FROM sqlite_master WHERE type='trigger' AND name='tasks_fts_ai'"
).get() as { name: string } | undefined;
expect(missingTrigger).toBeUndefined();
expect(db.rebuildFts5Index()).toBe(true);
const searchRows = db.prepare(`
SELECT t.id FROM tasks t
JOIN tasks_fts fts ON t.rowid = fts.rowid
WHERE tasks_fts MATCH 'Rebuild'
`).all() as Array<{ id: string }>;
expect(searchRows.some((row) => row.id === "FN-FTS-REBUILD")).toBe(true);
});
it("checkFts5Integrity returns true for healthy index", () => {
expect(db.checkFts5Integrity()).toBe(true);
});
it("checkFts5Integrity returns false when integrity-check command fails", () => {
const execSpy = vi.spyOn((db as any).db, "exec");
execSpy.mockImplementation((sql: string) => {
if (sql.includes("integrity-check")) {
throw new Error("corruption found reading blob");
}
return undefined as any;
});
expect(db.checkFts5Integrity()).toBe(false);
});
it("isFts5CorruptionError detects known corruption signatures", () => {
expect(db.isFts5CorruptionError(new Error("database disk image is malformed"))).toBe(true);
expect(db.isFts5CorruptionError(new Error("FTS5 index corrupt at segment 4"))).toBe(true);
expect(db.isFts5CorruptionError(new Error("some other sqlite error"))).toBe(false);
});
});
describe("Database FTS5 guard behavior", () => {
it("rebuildFts5Index returns false when FTS5 is unavailable", async () => {
const prevEnv = process.env.FUSION_DISABLE_FTS5;
process.env.FUSION_DISABLE_FTS5 = "1";
const tmpDir = makeTmpDir();
const fusionDir = join(tmpDir, ".fusion");
const localDb = new Database(fusionDir);
try {
localDb.init();
expect(localDb.rebuildFts5Index()).toBe(false);
} finally {
localDb.close();
await rm(tmpDir, { recursive: true, force: true });
if (prevEnv === undefined) {
delete process.env.FUSION_DISABLE_FTS5;
} else {
process.env.FUSION_DISABLE_FTS5 = prevEnv;
}
}
});
});
describe("createDatabase factory", () => {

View File

@@ -10081,6 +10081,43 @@ describe("RunMutationContext", () => {
});
});
describe("FTS5 corruption recovery during upsert", () => {
it("rebuilds FTS5 and retries once when upsert fails with an FTS corruption error", async () => {
const db = store.getDatabase();
const rebuildSpy = vi.spyOn(db, "rebuildFts5Index").mockReturnValue(true);
const upsertSpy = vi.spyOn(store as any, "upsertTask");
const originalUpsert = upsertSpy.getMockImplementation();
upsertSpy
.mockImplementationOnce(() => {
throw new Error("SQLITE_CORRUPT: corruption found reading blob in fts5");
})
.mockImplementation((task: Task) => {
if (originalUpsert) {
return originalUpsert(task);
}
return (TaskStore.prototype as any).upsertTask.call(store, task);
});
const created = await store.createTask({ description: "Recover from FTS corruption" });
expect(created.id).toBeDefined();
expect(rebuildSpy).toHaveBeenCalledTimes(1);
expect(upsertSpy).toHaveBeenCalledTimes(2);
});
it("propagates non-FTS errors without rebuild", async () => {
const db = store.getDatabase();
const rebuildSpy = vi.spyOn(db, "rebuildFts5Index").mockReturnValue(true);
vi.spyOn(store as any, "upsertTask").mockImplementationOnce(() => {
throw new Error("constraint failed");
});
await expect(store.createTask({ description: "Should fail" })).rejects.toThrow("constraint failed");
expect(rebuildSpy).not.toHaveBeenCalled();
});
});
describe("clearStaleBaseBranchReferences (FN-2165)", () => {
it("nulls baseBranch on live tasks that reference a deleted branch", async () => {
const upstream = await store.createTask({ description: "Upstream" });