Skip to content

Commit 4ffa141

Browse files
authored
Fix repeated SQLite FTS startup rebuilds (emdash-cms#638)
1 parent 7efc17d commit 4ffa141

3 files changed

Lines changed: 113 additions & 4 deletions

File tree

.changeset/ten-flies-admire.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"emdash": patch
3+
---
4+
5+
Fixes repeated FTS startup rebuilds on SQLite by verifying indexed row counts against the FTS shadow table.

packages/core/src/search/fts-manager.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,6 @@ export class FTSManager {
113113
const contentTable = this.getContentTableName(collectionSlug);
114114
const fieldList = searchableFields.join(", ");
115115
const newFieldList = searchableFields.map((f) => `NEW.${f}`).join(", ");
116-
117116
// Insert trigger - only index non-deleted content
118117
await sql
119118
.raw(`
@@ -355,6 +354,7 @@ export class FTSManager {
355354
if (!isSqlite(this.db)) return null;
356355
this.validateInputs(collectionSlug);
357356
const ftsTable = this.getFtsTableName(collectionSlug);
357+
const ftsDocsizeTable = `${ftsTable}_docsize`;
358358

359359
// Check if table exists
360360
if (!(await this.ftsTableExists(collectionSlug))) {
@@ -363,7 +363,7 @@ export class FTSManager {
363363

364364
// Count indexed rows
365365
const result = await sql<{ count: number }>`
366-
SELECT COUNT(*) as count FROM "${sql.raw(ftsTable)}"
366+
SELECT COUNT(*) as count FROM "${sql.raw(ftsDocsizeTable)}"
367367
`.execute(this.db);
368368

369369
return {
@@ -382,10 +382,19 @@ export class FTSManager {
382382
if (!isSqlite(this.db)) return false;
383383
this.validateInputs(collectionSlug);
384384
const ftsTable = this.getFtsTableName(collectionSlug);
385+
const ftsDocsizeTable = `${ftsTable}_docsize`;
385386
const contentTable = this.getContentTableName(collectionSlug);
387+
const fields = await this.getSearchableFields(collectionSlug);
388+
const config = await this.getSearchConfig(collectionSlug);
386389

387390
if (!(await this.ftsTableExists(collectionSlug))) {
388-
return false;
391+
if (!config?.enabled || fields.length === 0) {
392+
return false;
393+
}
394+
395+
console.warn(`FTS index for "${collectionSlug}" is missing. Rebuilding.`);
396+
await this.rebuildIndex(collectionSlug, fields, config.weights);
397+
return true;
389398
}
390399

391400
// Check 1: Row count mismatch
@@ -394,8 +403,12 @@ export class FTSManager {
394403
WHERE deleted_at IS NULL
395404
`.execute(this.db);
396405

406+
// For external-content FTS tables, COUNT(*) on the virtual table is
407+
// answered from the backing content table, including soft-deleted rows.
408+
// The docsize shadow table tracks the rows actually present in the
409+
// full-text index, which is what we need for repair decisions.
397410
const ftsCount = await sql<{ count: number }>`
398-
SELECT COUNT(*) as count FROM "${sql.raw(ftsTable)}"
411+
SELECT COUNT(*) as count FROM "${sql.raw(ftsDocsizeTable)}"
399412
`.execute(this.db);
400413

401414
const contentRows = contentCount.rows[0]?.count ?? 0;
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import type { Kysely } from "kysely";
2+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
3+
4+
import { ContentRepository } from "../../../src/database/repositories/content.js";
5+
import type { Database } from "../../../src/database/types.js";
6+
import { SchemaRegistry } from "../../../src/schema/registry.js";
7+
import { FTSManager } from "../../../src/search/fts-manager.js";
8+
import { searchWithDb } from "../../../src/search/query.js";
9+
import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js";
10+
11+
describe("FTS repair", () => {
12+
let db: Kysely<Database>;
13+
let registry: SchemaRegistry;
14+
let repo: ContentRepository;
15+
let ftsManager: FTSManager;
16+
let gameId: string;
17+
18+
beforeEach(async () => {
19+
db = await setupTestDatabase();
20+
registry = new SchemaRegistry(db);
21+
repo = new ContentRepository(db);
22+
ftsManager = new FTSManager(db);
23+
24+
await registry.createCollection({
25+
slug: "game",
26+
label: "Games",
27+
labelSingular: "Game",
28+
supports: ["search"],
29+
});
30+
await registry.createField("game", {
31+
slug: "title",
32+
label: "Title",
33+
type: "string",
34+
searchable: true,
35+
});
36+
await registry.createField("game", {
37+
slug: "blurb",
38+
label: "Blurb",
39+
type: "text",
40+
searchable: true,
41+
});
42+
43+
const created = await repo.create({
44+
type: "game",
45+
slug: "trail-of-cthulhu",
46+
status: "published",
47+
publishedAt: new Date().toISOString(),
48+
data: {
49+
title: "Trail of Cthulhu",
50+
blurb: "Investigative horror in the Cthulhu mythos.",
51+
},
52+
});
53+
gameId = created.id;
54+
55+
await ftsManager.enableSearch("game");
56+
});
57+
58+
afterEach(async () => {
59+
await teardownTestDatabase(db);
60+
});
61+
62+
it("recreates a missing FTS table when search remains enabled", async () => {
63+
expect(await ftsManager.ftsTableExists("game")).toBe(true);
64+
65+
await ftsManager.dropFtsTable("game");
66+
67+
expect(await ftsManager.ftsTableExists("game")).toBe(false);
68+
expect(
69+
await searchWithDb(db, "cthulhu", {
70+
collections: ["game"],
71+
status: "published",
72+
}),
73+
).toEqual({ items: [] });
74+
75+
await expect(ftsManager.verifyAndRepairAll()).resolves.toBe(1);
76+
expect(await ftsManager.ftsTableExists("game")).toBe(true);
77+
78+
const repaired = await searchWithDb(db, "cthulhu", {
79+
collections: ["game"],
80+
status: "published",
81+
});
82+
83+
expect(repaired.items).toHaveLength(1);
84+
expect(repaired.items[0]?.slug).toBe("trail-of-cthulhu");
85+
});
86+
87+
it("keeps the FTS index in sync after soft delete", async () => {
88+
await expect(repo.delete("game", gameId)).resolves.toBe(true);
89+
await expect(ftsManager.verifyAndRepairAll()).resolves.toBe(0);
90+
});
91+
});

0 commit comments

Comments
 (0)