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

@@ -704,6 +704,86 @@ export class Database {
return this._fts5Available;
}
/**
* Rebuild the task FTS5 index and maintenance triggers from scratch.
* Returns false when FTS5 is unavailable in this runtime.
*/
rebuildFts5Index(): boolean {
if (!this._fts5Available) {
return false;
}
try {
this.db.exec("DROP TRIGGER IF EXISTS tasks_fts_ai");
this.db.exec("DROP TRIGGER IF EXISTS tasks_fts_au");
this.db.exec("DROP TRIGGER IF EXISTS tasks_fts_ad");
this.db.exec("DROP TABLE IF EXISTS tasks_fts");
this.db.exec(`
CREATE VIRTUAL TABLE IF NOT EXISTS tasks_fts USING fts5(
id,
title,
description,
comments,
content='tasks',
content_rowid='rowid'
)
`);
this.db.exec(`
CREATE TRIGGER IF NOT EXISTS tasks_fts_ai AFTER INSERT ON tasks BEGIN
INSERT INTO tasks_fts(rowid, id, title, description, comments)
VALUES (new.rowid, new.id, COALESCE(new.title, ''), new.description, COALESCE(new.comments, '[]'));
END
`);
const hasTaskTitle = this.hasColumn("tasks", "title");
const updateColumns = hasTaskTitle
? "id, title, description, comments"
: "id, description, comments";
const oldTitle = hasTaskTitle ? "COALESCE(old.title, '')" : "''";
const newTitle = hasTaskTitle ? "COALESCE(new.title, '')" : "''";
this.db.exec(`
CREATE TRIGGER IF NOT EXISTS tasks_fts_au AFTER UPDATE OF ${updateColumns} ON tasks BEGIN
INSERT INTO tasks_fts(tasks_fts, rowid, id, title, description, comments)
VALUES('delete', old.rowid, old.id, ${oldTitle}, old.description, COALESCE(old.comments, '[]'));
INSERT INTO tasks_fts(rowid, id, title, description, comments)
VALUES (new.rowid, new.id, ${newTitle}, new.description, COALESCE(new.comments, '[]'));
END
`);
this.db.exec(`
CREATE TRIGGER IF NOT EXISTS tasks_fts_ad AFTER DELETE ON tasks BEGIN
INSERT INTO tasks_fts(tasks_fts, rowid, id, title, description, comments)
VALUES('delete', old.rowid, old.id, COALESCE(old.title, ''), old.description, COALESCE(old.comments, '[]'));
END
`);
this.db.exec("INSERT INTO tasks_fts(tasks_fts) VALUES('rebuild')");
return true;
} catch (error) {
console.warn("[fusion:db] Failed to rebuild FTS5 index", error);
throw error;
}
}
/**
* Run FTS5 integrity check. Returns true when healthy or unavailable.
*/
checkFts5Integrity(): boolean {
if (!this._fts5Available) {
return true;
}
try {
this.db.exec("INSERT INTO tasks_fts(tasks_fts) VALUES('integrity-check')");
return true;
} catch {
return false;
}
}
/**
* Initialize the database: create tables if they don't exist
* and seed meta values.
@@ -1885,6 +1965,19 @@ export class Database {
return Boolean(row);
}
/**
* Check whether an error appears to be an FTS5 corruption/integrity failure.
*/
isFts5CorruptionError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error ?? "");
const lower = message.toLowerCase();
return (
lower.includes("corruption found reading blob") ||
lower.includes("database disk image is malformed") ||
(lower.includes("fts5") && lower.includes("corrupt"))
);
}
/**
* Check whether a table has a given column.
*/