fix: guard SQLite FTS5 at runtime and fall back to LIKE search
On Node builds whose bundled node:sqlite was compiled without SQLITE_ENABLE_FTS5 (older 22.x LTS), `fn dashboard` crashed on first run with `Error: no such module: fts5` during schema migration 21. Database and ArchiveDatabase now probe FTS5 at startup via a disposable virtual table. When unavailable, migrations 21 and 35 skip the tasks_fts DDL, ArchiveDatabase skips the archived_tasks_fts block, and TaskStore.searchTasks / ArchiveDatabase.search fall back to LIKE scans over id/title/description/comments with ESCAPE-aware patterns. Set FUSION_DISABLE_FTS5=1 to force the fallback on runtimes where FTS5 is available but undesirable (e.g. reproducing fresh-install behavior in tests). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
5
.changeset/fts5-runtime-guard.md
Normal file
5
.changeset/fts5-runtime-guard.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Guard SQLite FTS5 usage so Fusion starts cleanly on Node builds whose bundled `node:sqlite` was compiled without FTS5. On affected systems, `fn dashboard` previously crashed on first run with `Error: no such module: fts5` during schema migration. The Database and ArchiveDatabase now probe for FTS5 at startup and skip the virtual table + triggers when unavailable; `TaskStore.searchTasks` and `ArchiveDatabase.search` fall back to LIKE-based scans. Set `FUSION_DISABLE_FTS5=1` to force the fallback on runtimes where FTS5 is present but undesirable.
|
||||||
@@ -2,8 +2,9 @@ import { DatabaseSync } from "node:sqlite";
|
|||||||
import { existsSync, mkdirSync } from "node:fs";
|
import { existsSync, mkdirSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import type { ArchivedTaskEntry } from "./types.js";
|
import type { ArchivedTaskEntry } from "./types.js";
|
||||||
|
import { probeFts5 } from "./db.js";
|
||||||
|
|
||||||
const ARCHIVE_SCHEMA_SQL = `
|
const BASE_SCHEMA_SQL = `
|
||||||
CREATE TABLE IF NOT EXISTS archived_tasks (
|
CREATE TABLE IF NOT EXISTS archived_tasks (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
taskJson TEXT NOT NULL,
|
taskJson TEXT NOT NULL,
|
||||||
@@ -19,7 +20,9 @@ CREATE TABLE IF NOT EXISTS archived_tasks (
|
|||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idxArchivedTasksArchivedAt ON archived_tasks(archivedAt);
|
CREATE INDEX IF NOT EXISTS idxArchivedTasksArchivedAt ON archived_tasks(archivedAt);
|
||||||
CREATE INDEX IF NOT EXISTS idxArchivedTasksCreatedAt ON archived_tasks(createdAt);
|
CREATE INDEX IF NOT EXISTS idxArchivedTasksCreatedAt ON archived_tasks(createdAt);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const FTS5_SCHEMA_SQL = `
|
||||||
CREATE VIRTUAL TABLE IF NOT EXISTS archived_tasks_fts USING fts5(
|
CREATE VIRTUAL TABLE IF NOT EXISTS archived_tasks_fts USING fts5(
|
||||||
id,
|
id,
|
||||||
title,
|
title,
|
||||||
@@ -49,18 +52,28 @@ END;
|
|||||||
|
|
||||||
export class ArchiveDatabase {
|
export class ArchiveDatabase {
|
||||||
private db: DatabaseSync;
|
private db: DatabaseSync;
|
||||||
|
private readonly _fts5Available: boolean;
|
||||||
|
|
||||||
constructor(private readonly kbDir: string) {
|
constructor(kbDir: string) {
|
||||||
if (!existsSync(kbDir)) {
|
if (!existsSync(kbDir)) {
|
||||||
mkdirSync(kbDir, { recursive: true });
|
mkdirSync(kbDir, { recursive: true });
|
||||||
}
|
}
|
||||||
this.db = new DatabaseSync(join(kbDir, "archive.db"));
|
this.db = new DatabaseSync(join(kbDir, "archive.db"));
|
||||||
this.db.exec("PRAGMA journal_mode = WAL");
|
this.db.exec("PRAGMA journal_mode = WAL");
|
||||||
this.db.exec("PRAGMA busy_timeout = 5000");
|
this.db.exec("PRAGMA busy_timeout = 5000");
|
||||||
|
this._fts5Available = probeFts5(this.db);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when this SQLite build has FTS5. See db.ts#probeFts5. */
|
||||||
|
get fts5Available(): boolean {
|
||||||
|
return this._fts5Available;
|
||||||
}
|
}
|
||||||
|
|
||||||
init(): void {
|
init(): void {
|
||||||
this.db.exec(ARCHIVE_SCHEMA_SQL);
|
this.db.exec(BASE_SCHEMA_SQL);
|
||||||
|
if (this._fts5Available) {
|
||||||
|
this.db.exec(FTS5_SCHEMA_SQL);
|
||||||
|
}
|
||||||
this.addColumnIfMissing("archived_tasks", "prompt", "TEXT");
|
this.addColumnIfMissing("archived_tasks", "prompt", "TEXT");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,7 +122,30 @@ export class ArchiveDatabase {
|
|||||||
this.db.prepare("DELETE FROM archived_tasks WHERE id = ?").run(id);
|
this.db.prepare("DELETE FROM archived_tasks WHERE id = ?").run(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-text search over archived tasks. Accepts a raw user query and routes
|
||||||
|
* through FTS5 when available, or a LIKE-based scan when not.
|
||||||
|
*/
|
||||||
search(query: string, limit: number): ArchivedTaskEntry[] {
|
search(query: string, limit: number): ArchivedTaskEntry[] {
|
||||||
|
const trimmed = query?.trim();
|
||||||
|
if (!trimmed) return [];
|
||||||
|
|
||||||
|
const tokens = trimmed
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter((t) => t.length > 0)
|
||||||
|
.map((t) => t.replace(/["{}:*^+()]/g, ""))
|
||||||
|
.filter((t) => t.length > 0);
|
||||||
|
if (tokens.length === 0) return [];
|
||||||
|
|
||||||
|
if (this._fts5Available) {
|
||||||
|
const ftsQuery = tokens
|
||||||
|
.map((token) => {
|
||||||
|
if (/[":(){}*^+-]/.test(token)) {
|
||||||
|
return `"${token.replace(/"/g, '\\"')}"`;
|
||||||
|
}
|
||||||
|
return token;
|
||||||
|
})
|
||||||
|
.join(" OR ");
|
||||||
const rows = this.db.prepare(`
|
const rows = this.db.prepare(`
|
||||||
SELECT a.taskJson
|
SELECT a.taskJson
|
||||||
FROM archived_tasks a
|
FROM archived_tasks a
|
||||||
@@ -117,7 +153,29 @@ export class ArchiveDatabase {
|
|||||||
WHERE archived_tasks_fts MATCH ?
|
WHERE archived_tasks_fts MATCH ?
|
||||||
ORDER BY rank
|
ORDER BY rank
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
`).all(query, limit) as Array<{ taskJson: string }>;
|
`).all(ftsQuery, limit) as Array<{ taskJson: string }>;
|
||||||
|
return rows.map((row) => JSON.parse(row.taskJson) as ArchivedTaskEntry);
|
||||||
|
}
|
||||||
|
|
||||||
|
// LIKE fallback
|
||||||
|
const searchColumns = ["id", "title", "description", "comments"];
|
||||||
|
const perTokenClause = `(${searchColumns
|
||||||
|
.map((c) => `"${c}" LIKE ? ESCAPE '\\'`)
|
||||||
|
.join(" OR ")})`;
|
||||||
|
const whereTokens = tokens.map(() => perTokenClause).join(" OR ");
|
||||||
|
const params: (string | number)[] = [];
|
||||||
|
for (const token of tokens) {
|
||||||
|
const pattern = `%${token.replace(/[\\%_]/g, "\\$&")}%`;
|
||||||
|
for (let i = 0; i < searchColumns.length; i++) params.push(pattern);
|
||||||
|
}
|
||||||
|
params.push(limit);
|
||||||
|
const rows = this.db.prepare(`
|
||||||
|
SELECT taskJson
|
||||||
|
FROM archived_tasks
|
||||||
|
WHERE ${whereTokens}
|
||||||
|
ORDER BY archivedAt DESC
|
||||||
|
LIMIT ?
|
||||||
|
`).all(...params) as Array<{ taskJson: string }>;
|
||||||
return rows.map((row) => JSON.parse(row.taskJson) as ArchivedTaskEntry);
|
return rows.map((row) => JSON.parse(row.taskJson) as ArchivedTaskEntry);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,33 @@ export function fromJson<T>(json: string | null | undefined): T | undefined {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Runtime capability probes ────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Probe whether this SQLite build supports the FTS5 extension.
|
||||||
|
*
|
||||||
|
* Node's built-in `node:sqlite` only exposes FTS5 when the bundled SQLite was
|
||||||
|
* compiled with `SQLITE_ENABLE_FTS5`. Newer Node builds (≥ 22.13, 24, 25) have
|
||||||
|
* it on; some older 22.x LTS builds do not, and attempting to
|
||||||
|
* `CREATE VIRTUAL TABLE … USING fts5(…)` on those throws `no such module: fts5`.
|
||||||
|
*
|
||||||
|
* The probe creates and drops a disposable virtual table. Set
|
||||||
|
* `FUSION_DISABLE_FTS5=1` to force the LIKE fallback path in environments where
|
||||||
|
* FTS5 is available at probe time but undesirable at runtime (e.g. tests).
|
||||||
|
*/
|
||||||
|
export function probeFts5(db: DatabaseSync): boolean {
|
||||||
|
if (process.env.FUSION_DISABLE_FTS5 === "1" || process.env.FUSION_DISABLE_FTS5 === "true") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
db.exec("CREATE VIRTUAL TABLE IF NOT EXISTS __fusion_fts5_probe USING fts5(x)");
|
||||||
|
db.exec("DROP TABLE IF EXISTS __fusion_fts5_probe");
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Schema Definition ────────────────────────────────────────────────
|
// ── Schema Definition ────────────────────────────────────────────────
|
||||||
|
|
||||||
const SCHEMA_VERSION = 42;
|
const SCHEMA_VERSION = 42;
|
||||||
@@ -589,6 +616,7 @@ export class Database {
|
|||||||
private readonly dbPath: string;
|
private readonly dbPath: string;
|
||||||
/** Tracks transaction nesting depth for savepoint-based nested transactions. */
|
/** Tracks transaction nesting depth for savepoint-based nested transactions. */
|
||||||
private transactionDepth = 0;
|
private transactionDepth = 0;
|
||||||
|
private readonly _fts5Available: boolean;
|
||||||
|
|
||||||
constructor(kbDir: string) {
|
constructor(kbDir: string) {
|
||||||
this.dbPath = join(kbDir, "fusion.db");
|
this.dbPath = join(kbDir, "fusion.db");
|
||||||
@@ -610,6 +638,18 @@ export class Database {
|
|||||||
this.db.exec("PRAGMA busy_timeout = 5000");
|
this.db.exec("PRAGMA busy_timeout = 5000");
|
||||||
// Enable foreign key enforcement
|
// Enable foreign key enforcement
|
||||||
this.db.exec("PRAGMA foreign_keys = ON");
|
this.db.exec("PRAGMA foreign_keys = ON");
|
||||||
|
|
||||||
|
this._fts5Available = probeFts5(this.db);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when the underlying SQLite build has FTS5 (`CREATE VIRTUAL TABLE … USING fts5`).
|
||||||
|
* Node's bundled SQLite only exposes FTS5 when built with `SQLITE_ENABLE_FTS5`;
|
||||||
|
* older Node 22.x LTS builds do not. Consumers must fall back to LIKE-based scans
|
||||||
|
* when this is false. Override with `FUSION_DISABLE_FTS5=1` to force the fallback path.
|
||||||
|
*/
|
||||||
|
get fts5Available(): boolean {
|
||||||
|
return this._fts5Available;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -972,6 +1012,12 @@ export class Database {
|
|||||||
// up comment text, IDs, timestamps, and author names. This is acceptable for v1.
|
// up comment text, IDs, timestamps, and author names. This is acceptable for v1.
|
||||||
if (version < 21) {
|
if (version < 21) {
|
||||||
this.applyMigration(21, () => {
|
this.applyMigration(21, () => {
|
||||||
|
if (!this._fts5Available) {
|
||||||
|
// FTS5 unavailable (older node:sqlite build). Bump the migration
|
||||||
|
// version so we don't retry forever, and fall back to LIKE-based
|
||||||
|
// search in TaskStore.searchTasks / ArchiveDatabase.search.
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Create FTS5 virtual table for full-text search
|
// Create FTS5 virtual table for full-text search
|
||||||
// Note: Column names must match the tasks table for external content mode to work
|
// Note: Column names must match the tasks table for external content mode to work
|
||||||
this.db.exec(`
|
this.db.exec(`
|
||||||
@@ -1501,6 +1547,11 @@ export class Database {
|
|||||||
// log-only executor updates should not churn or bloat the FTS index.
|
// log-only executor updates should not churn or bloat the FTS index.
|
||||||
if (version < 35) {
|
if (version < 35) {
|
||||||
this.applyMigration(35, () => {
|
this.applyMigration(35, () => {
|
||||||
|
if (!this._fts5Available) {
|
||||||
|
// tasks_fts does not exist when FTS5 is unavailable; nothing to
|
||||||
|
// rebuild or re-trigger.
|
||||||
|
return;
|
||||||
|
}
|
||||||
const hasTaskTitle = this.hasColumn("tasks", "title");
|
const hasTaskTitle = this.hasColumn("tasks", "title");
|
||||||
const updateColumns = hasTaskTitle
|
const updateColumns = hasTaskTitle
|
||||||
? "id, title, description, comments"
|
? "id, title, description, comments"
|
||||||
|
|||||||
283
packages/core/src/fts5-guard.test.ts
Normal file
283
packages/core/src/fts5-guard.test.ts
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
/**
|
||||||
|
* Regression tests for the FTS5 runtime guard.
|
||||||
|
*
|
||||||
|
* On Node builds whose bundled SQLite lacks FTS5 (older 22.x LTS),
|
||||||
|
* `CREATE VIRTUAL TABLE … USING fts5(…)` throws `no such module: fts5`
|
||||||
|
* and the dashboard crashes on first-run DB migration. These tests lock in
|
||||||
|
* the fallback path: init() must succeed, and search() must route through
|
||||||
|
* LIKE-based SQL.
|
||||||
|
*
|
||||||
|
* The `FUSION_DISABLE_FTS5=1` env var forces the probe to report FTS5 as
|
||||||
|
* unavailable even on runtimes that support it — so the CI machine can
|
||||||
|
* exercise the same code path a fresh install on an old Node would hit.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||||
|
import { mkdtempSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { rm } from "node:fs/promises";
|
||||||
|
import { Database } from "./db.js";
|
||||||
|
import { ArchiveDatabase } from "./archive-db.js";
|
||||||
|
import { TaskStore } from "./store.js";
|
||||||
|
|
||||||
|
function makeTmpDir(): string {
|
||||||
|
return mkdtempSync(join(tmpdir(), "kb-fts5-guard-test-"));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("FTS5 runtime guard", () => {
|
||||||
|
let prevEnv: string | undefined;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
prevEnv = process.env.FUSION_DISABLE_FTS5;
|
||||||
|
process.env.FUSION_DISABLE_FTS5 = "1";
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (prevEnv === undefined) {
|
||||||
|
delete process.env.FUSION_DISABLE_FTS5;
|
||||||
|
} else {
|
||||||
|
process.env.FUSION_DISABLE_FTS5 = prevEnv;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Database", () => {
|
||||||
|
let tmpDir: string;
|
||||||
|
let kbDir: string;
|
||||||
|
let db: Database;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tmpDir = makeTmpDir();
|
||||||
|
kbDir = join(tmpDir, ".fusion");
|
||||||
|
db = new Database(kbDir);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
try { db.close(); } catch { /* already closed */ }
|
||||||
|
await rm(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports fts5Available=false when FUSION_DISABLE_FTS5 is set", () => {
|
||||||
|
expect(db.fts5Available).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("init() does not throw when FTS5 is unavailable", () => {
|
||||||
|
expect(() => db.init()).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips creating tasks_fts virtual table", () => {
|
||||||
|
db.init();
|
||||||
|
const row = db.prepare(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='tasks_fts'"
|
||||||
|
).get() as { name: string } | undefined;
|
||||||
|
expect(row).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips creating FTS5 triggers", () => {
|
||||||
|
db.init();
|
||||||
|
const triggers = db.prepare(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='trigger'"
|
||||||
|
).all() as { name: string }[];
|
||||||
|
const ftsTriggers = triggers.filter((t) => t.name.startsWith("tasks_fts_"));
|
||||||
|
expect(ftsTriggers).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still advances the schemaVersion so migrations don't retry", () => {
|
||||||
|
db.init();
|
||||||
|
const row = db.prepare(
|
||||||
|
"SELECT value FROM __meta WHERE key = 'schemaVersion'"
|
||||||
|
).get() as { value: string };
|
||||||
|
// Migration 21 guards FTS5; 35 also guards. The final version is
|
||||||
|
// the full SCHEMA_VERSION regardless of FTS5 availability.
|
||||||
|
expect(Number(row.value)).toBeGreaterThanOrEqual(35);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("TaskStore.searchTasks LIKE fallback", () => {
|
||||||
|
let rootDir: string;
|
||||||
|
let globalDir: string;
|
||||||
|
let store: TaskStore;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
rootDir = makeTmpDir();
|
||||||
|
globalDir = makeTmpDir();
|
||||||
|
store = new TaskStore(rootDir, globalDir);
|
||||||
|
await store.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
store.close();
|
||||||
|
await rm(rootDir, { recursive: true, force: true });
|
||||||
|
await rm(globalDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finds tasks by exact id match", async () => {
|
||||||
|
await store.createTask({ description: "First task" });
|
||||||
|
await store.createTask({ description: "Second task" });
|
||||||
|
|
||||||
|
const results = await store.searchTasks("FN-001");
|
||||||
|
expect(results).toHaveLength(1);
|
||||||
|
expect(results[0].id).toBe("FN-001");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finds tasks by title substring", async () => {
|
||||||
|
await store.createTask({ title: "Fix login bug", description: "Login issue" });
|
||||||
|
await store.createTask({ title: "Add dashboard feature", description: "New UI" });
|
||||||
|
|
||||||
|
const results = await store.searchTasks("dashboard");
|
||||||
|
expect(results).toHaveLength(1);
|
||||||
|
expect(results[0].title).toBe("Add dashboard feature");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finds tasks by description substring", async () => {
|
||||||
|
await store.createTask({ description: "Fix the login button on the homepage" });
|
||||||
|
await store.createTask({ description: "Update the settings page layout" });
|
||||||
|
|
||||||
|
const results = await store.searchTasks("homepage");
|
||||||
|
expect(results).toHaveLength(1);
|
||||||
|
expect(results[0].description).toContain("homepage");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finds tasks by comment text", async () => {
|
||||||
|
const task = await store.createTask({ description: "A task" });
|
||||||
|
await store.addComment(task.id, "Need to prioritize the xylophone implementation", "tester");
|
||||||
|
|
||||||
|
const results = await store.searchTasks("xylophone");
|
||||||
|
expect(results).toHaveLength(1);
|
||||||
|
expect(results[0].id).toBe(task.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is case insensitive (LIKE on SQLite is ASCII-case-insensitive)", async () => {
|
||||||
|
await store.createTask({ title: "UPPERCASE SEARCH TEST", description: "x" });
|
||||||
|
|
||||||
|
const results = await store.searchTasks("uppercase");
|
||||||
|
expect(results).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses OR semantics across tokens", async () => {
|
||||||
|
await store.createTask({ title: "Fix login", description: "Button issues" });
|
||||||
|
await store.createTask({ title: "Add dashboard", description: "New features" });
|
||||||
|
|
||||||
|
const results = await store.searchTasks("login dashboard");
|
||||||
|
expect(results).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty array for non-matching query", async () => {
|
||||||
|
await store.createTask({ description: "Regular task description" });
|
||||||
|
|
||||||
|
const results = await store.searchTasks("xyznonexistent12345");
|
||||||
|
expect(results).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("escapes LIKE metacharacters in user input", async () => {
|
||||||
|
await store.createTask({ description: "this has 100% coverage" });
|
||||||
|
await store.createTask({ description: "the word percent does not have a literal" });
|
||||||
|
|
||||||
|
// "100%" with a literal percent should match only the first task,
|
||||||
|
// not every task via wildcard.
|
||||||
|
const results = await store.searchTasks("100%");
|
||||||
|
expect(results).toHaveLength(1);
|
||||||
|
expect(results[0].description).toContain("100%");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("respects limit option", async () => {
|
||||||
|
await store.createTask({ title: "widget alpha", description: "x" });
|
||||||
|
await store.createTask({ title: "widget beta", description: "x" });
|
||||||
|
await store.createTask({ title: "widget gamma", description: "x" });
|
||||||
|
|
||||||
|
const results = await store.searchTasks("widget", { limit: 2 });
|
||||||
|
expect(results).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes archived tasks when includeArchived is false", async () => {
|
||||||
|
const uniqueTerm = `archguardterm${Date.now()}`;
|
||||||
|
const task = await store.createTask({ description: `archived ${uniqueTerm}` });
|
||||||
|
await store.moveTask(task.id, "todo");
|
||||||
|
await store.moveTask(task.id, "in-progress");
|
||||||
|
await store.moveTask(task.id, "in-review");
|
||||||
|
await store.moveTask(task.id, "done");
|
||||||
|
await store.archiveTask(task.id);
|
||||||
|
|
||||||
|
const withArchived = await store.searchTasks(uniqueTerm);
|
||||||
|
const withoutArchived = await store.searchTasks(uniqueTerm, { includeArchived: false });
|
||||||
|
|
||||||
|
expect(withArchived.some((r) => r.id === task.id)).toBe(true);
|
||||||
|
expect(withoutArchived.some((r) => r.id === task.id)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ArchiveDatabase.search LIKE fallback", () => {
|
||||||
|
let tmpDir: string;
|
||||||
|
let kbDir: string;
|
||||||
|
let archive: ArchiveDatabase;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tmpDir = makeTmpDir();
|
||||||
|
kbDir = join(tmpDir, ".fusion");
|
||||||
|
archive = new ArchiveDatabase(kbDir);
|
||||||
|
archive.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
try { archive.close(); } catch { /* already closed */ }
|
||||||
|
await rm(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports fts5Available=false under the env override", () => {
|
||||||
|
expect(archive.fts5Available).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("init() does not throw when FTS5 is unavailable", () => {
|
||||||
|
// init was called in beforeEach; re-running should still work
|
||||||
|
expect(() => archive.init()).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips creating archived_tasks_fts virtual table", () => {
|
||||||
|
// Direct probe via sqlite_master — exposed through Database's prepared
|
||||||
|
// statement interface isn't available here, so we test via a known
|
||||||
|
// side effect: search() must still return results.
|
||||||
|
archive.upsert({
|
||||||
|
id: "FN-ARCH-001",
|
||||||
|
archivedAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
createdAt: "2025-12-01T00:00:00.000Z",
|
||||||
|
updatedAt: "2025-12-02T00:00:00.000Z",
|
||||||
|
title: "archived widget alpha",
|
||||||
|
description: "this is an archived task about widgets",
|
||||||
|
comments: [],
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const results = archive.search("widget", 10);
|
||||||
|
expect(results).toHaveLength(1);
|
||||||
|
expect(results[0].id).toBe("FN-ARCH-001");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finds archived tasks via LIKE across id, title, description, comments", () => {
|
||||||
|
archive.upsert({
|
||||||
|
id: "FN-ARCH-002",
|
||||||
|
archivedAt: "2026-01-02T00:00:00.000Z",
|
||||||
|
createdAt: "2025-12-01T00:00:00.000Z",
|
||||||
|
updatedAt: "2025-12-02T00:00:00.000Z",
|
||||||
|
title: "unrelated",
|
||||||
|
description: "task mentions xylophone in the body",
|
||||||
|
comments: [],
|
||||||
|
} as any);
|
||||||
|
archive.upsert({
|
||||||
|
id: "FN-ARCH-003",
|
||||||
|
archivedAt: "2026-01-03T00:00:00.000Z",
|
||||||
|
createdAt: "2025-12-03T00:00:00.000Z",
|
||||||
|
updatedAt: "2025-12-03T00:00:00.000Z",
|
||||||
|
title: "unrelated",
|
||||||
|
description: "no match here",
|
||||||
|
comments: [],
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const results = archive.search("xylophone", 10);
|
||||||
|
expect(results.map((r) => r.id)).toEqual(["FN-ARCH-002"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty array for empty or whitespace-only query", () => {
|
||||||
|
expect(archive.search("", 10)).toEqual([]);
|
||||||
|
expect(archive.search(" ", 10)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1991,7 +1991,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
return this.listTasks(options);
|
return this.listTasks(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sanitize query for FTS5 safety: strip dangerous operators but preserve alphanumeric
|
// Sanitize query: strip FTS5 operators so both code paths see the same token set
|
||||||
const sanitizedTokens = trimmedQuery
|
const sanitizedTokens = trimmedQuery
|
||||||
.split(/\s+/)
|
.split(/\s+/)
|
||||||
.filter((token) => token.length > 0)
|
.filter((token) => token.length > 0)
|
||||||
@@ -2002,27 +2002,27 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
return this.listTasks(options);
|
return this.listTasks(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const limit = options?.limit ?? -1;
|
||||||
|
const offset = options?.offset ?? 0;
|
||||||
|
const offsetClause = offset > 0 ? ` OFFSET ${offset}` : "";
|
||||||
|
const includeArchived = options?.includeArchived ?? true;
|
||||||
|
const slim = options?.slim ?? false;
|
||||||
|
const selectClause = this.getTaskSelectClause(slim, "t");
|
||||||
|
|
||||||
|
let rows: any[];
|
||||||
|
if (this.db.fts5Available) {
|
||||||
// For FTS5 MATCH, quote tokens that contain special characters like hyphens
|
// For FTS5 MATCH, quote tokens that contain special characters like hyphens
|
||||||
// to prevent them from being interpreted as operators
|
// to prevent them from being interpreted as operators
|
||||||
const ftsQuery = sanitizedTokens
|
const ftsQuery = sanitizedTokens
|
||||||
.map((token) => {
|
.map((token) => {
|
||||||
// If token contains FTS5 special chars, wrap in double quotes
|
|
||||||
if (/[":(){}*^+-]/.test(token)) {
|
if (/[":(){}*^+-]/.test(token)) {
|
||||||
return `"${token.replace(/"/g, '\\"')}"`;
|
return `"${token.replace(/"/g, '\\"')}"`;
|
||||||
}
|
}
|
||||||
return token;
|
return token;
|
||||||
})
|
})
|
||||||
.join(" OR ");
|
.join(" OR ");
|
||||||
|
|
||||||
// Execute FTS query with ranking
|
|
||||||
const limit = options?.limit ?? -1;
|
|
||||||
const offset = options?.offset ?? 0;
|
|
||||||
const offsetClause = offset > 0 ? ` OFFSET ${offset}` : "";
|
|
||||||
const includeArchived = options?.includeArchived ?? true;
|
|
||||||
const whereClause = includeArchived ? "" : ` AND t."column" != 'archived'`;
|
const whereClause = includeArchived ? "" : ` AND t."column" != 'archived'`;
|
||||||
const selectClause = this.getTaskSelectClause(options?.slim ?? false, "t");
|
rows = this.db.prepare(`
|
||||||
|
|
||||||
const rows = this.db.prepare(`
|
|
||||||
SELECT ${selectClause} FROM tasks t
|
SELECT ${selectClause} FROM tasks t
|
||||||
JOIN tasks_fts fts ON t.rowid = fts.rowid
|
JOIN tasks_fts fts ON t.rowid = fts.rowid
|
||||||
WHERE tasks_fts MATCH ?
|
WHERE tasks_fts MATCH ?
|
||||||
@@ -2030,6 +2030,28 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
ORDER BY rank
|
ORDER BY rank
|
||||||
LIMIT ${limit >= 0 ? limit : -1}${offsetClause}
|
LIMIT ${limit >= 0 ? limit : -1}${offsetClause}
|
||||||
`).all(ftsQuery) as any[];
|
`).all(ftsQuery) as any[];
|
||||||
|
} else {
|
||||||
|
// LIKE fallback: any token matching any searchable column counts as a hit.
|
||||||
|
// Tokens are OR'd; per token we OR across id/title/description/comments.
|
||||||
|
// ESCAPE '\\' lets us include user input containing % or _ literally.
|
||||||
|
const searchColumns = ["id", "title", "description", "comments"];
|
||||||
|
const perTokenClause = `(${searchColumns
|
||||||
|
.map((c) => `t."${c}" LIKE ? ESCAPE '\\'`)
|
||||||
|
.join(" OR ")})`;
|
||||||
|
const whereTokens = sanitizedTokens.map(() => perTokenClause).join(" OR ");
|
||||||
|
const params: string[] = [];
|
||||||
|
for (const token of sanitizedTokens) {
|
||||||
|
const pattern = `%${token.replace(/[\\%_]/g, "\\$&")}%`;
|
||||||
|
for (let i = 0; i < searchColumns.length; i++) params.push(pattern);
|
||||||
|
}
|
||||||
|
const archivedClause = includeArchived ? "" : ` AND t."column" != 'archived'`;
|
||||||
|
rows = this.db.prepare(`
|
||||||
|
SELECT ${selectClause} FROM tasks t
|
||||||
|
WHERE (${whereTokens})${archivedClause}
|
||||||
|
ORDER BY t.createdAt ASC
|
||||||
|
LIMIT ${limit >= 0 ? limit : -1}${offsetClause}
|
||||||
|
`).all(...params) as any[];
|
||||||
|
}
|
||||||
|
|
||||||
const activeMatches = await Promise.all(rows.map(async (row) => {
|
const activeMatches = await Promise.all(rows.map(async (row) => {
|
||||||
const task = this.rowToTask(row);
|
const task = this.rowToTask(row);
|
||||||
@@ -2041,7 +2063,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
return steps.length > 0 ? { ...task, steps } : task;
|
return steps.length > 0 ? { ...task, steps } : task;
|
||||||
}));
|
}));
|
||||||
const archiveMatches = includeArchived
|
const archiveMatches = includeArchived
|
||||||
? this.archiveDb.search(ftsQuery, limit >= 0 ? limit : 100).map((entry) => this.archiveEntryToTask(entry, options?.slim ?? false))
|
? this.archiveDb.search(trimmedQuery, limit >= 0 ? limit : 100).map((entry) => this.archiveEntryToTask(entry, slim))
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
const matches = [...activeMatches, ...archiveMatches];
|
const matches = [...activeMatches, ...archiveMatches];
|
||||||
|
|||||||
Reference in New Issue
Block a user