feat(FN-1260): add full-text search for tasks and comments

- Add FTS5 virtual table with v21 database migration for task search
- Add searchTasks() method to TaskStore with FTS5 query support
- Add q= search parameter to GET /api/tasks route for server-side search
- Update useTasks hook and frontend API to support searchQuery prop
- Update Board.tsx and App.tsx to pass searchQuery through component hierarchy
- Add comprehensive tests for FTS5 index and searchTasks functionality
This commit is contained in:
gsxdsm
2026-04-09 10:17:02 -07:00
parent 6232b38937
commit dcbad95fd6
13 changed files with 571 additions and 53 deletions

View File

@@ -59,7 +59,7 @@ export function fromJson<T>(json: string | null | undefined): T | undefined {
// ── Schema Definition ────────────────────────────────────────────────
const SCHEMA_VERSION = 20;
const SCHEMA_VERSION = 21;
function normalizeTaskComments(
steeringComments: SteeringComment[] | undefined,
@@ -749,6 +749,69 @@ export class Database {
this.addColumnIfMissing("tasks", "checkedOutAt", "TEXT");
});
}
// FTS5 full-text search index for tasks.
// All task writes go through upsertTask() (called by atomicWriteTaskJson()),
// which does INSERT OR REPLACE INTO tasks. The SQLite triggers below fire on
// INSERT/UPDATE/DELETE and keep the FTS index in sync automatically.
// The comments column is a JSON array - FTS5 tokenizes the raw JSON which picks
// up comment text, IDs, timestamps, and author names. This is acceptable for v1.
if (version < 21) {
this.applyMigration(21, () => {
// Create FTS5 virtual table for full-text search
// Note: Column names must match the tasks table for external content mode to work
this.db.exec(`
CREATE VIRTUAL TABLE IF NOT EXISTS tasks_fts USING fts5(
id,
title,
description,
comments,
content='tasks',
content_rowid='rowid'
)
`);
// Populate FTS index from existing tasks
// Handle both older schemas (without title) and newer schemas (with title)
if (this.hasColumn("tasks", "title")) {
this.db.exec(`
INSERT INTO tasks_fts(rowid, id, title, description, comments)
SELECT rowid, id, COALESCE(title, ''), description, COALESCE(comments, '[]') FROM tasks
`);
} else {
this.db.exec(`
INSERT INTO tasks_fts(rowid, id, title, description, comments)
SELECT rowid, id, '', description, COALESCE(comments, '[]') FROM tasks
`);
}
// AFTER INSERT trigger - index new tasks
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
`);
// AFTER UPDATE trigger - reindex updated tasks (delete old + insert new)
this.db.exec(`
CREATE TRIGGER IF NOT EXISTS tasks_fts_au AFTER UPDATE 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, '[]'));
INSERT INTO tasks_fts(rowid, id, title, description, comments)
VALUES (new.rowid, new.id, COALESCE(new.title, ''), new.description, COALESCE(new.comments, '[]'));
END
`);
// AFTER DELETE trigger - remove deleted tasks from index
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
`);
});
}
}
/**