diff --git a/.changeset/chubby-ears-joke.md b/.changeset/chubby-ears-joke.md new file mode 100644 index 000000000..02d471af2 --- /dev/null +++ b/.changeset/chubby-ears-joke.md @@ -0,0 +1,5 @@ +--- +"emdash": patch +--- + +Fixes FTS5 table lifecycle in SchemaRegistry: createField now rebuilds the search index when a searchable field is added to a search-enabled collection; deleteField drops/rebuilds the FTS table when a searchable field is removed; deleteCollection drops the FTS virtual table before dropping the content table; updateCollection toggles the FTS table when search support is added or removed. Uses supports.includes("search") as the single source of truth for FTS state. diff --git a/packages/core/src/schema/registry.ts b/packages/core/src/schema/registry.ts index 97d8a2763..57ed8dc99 100644 --- a/packages/core/src/schema/registry.ts +++ b/packages/core/src/schema/registry.ts @@ -233,6 +233,21 @@ export class SchemaRegistry { throw new SchemaError("Failed to update collection", "UPDATE_FAILED"); } + if (input.supports !== undefined) { + const wasSearch = existing.supports.includes("search"); + const nowSearch = input.supports.includes("search"); + const ftsManager = new FTSManager(this.db); + + if (!wasSearch && nowSearch) { + const searchableFields = await ftsManager.getSearchableFields(slug); + if (searchableFields.length > 0) { + await ftsManager.rebuildIndex(slug, searchableFields); + } + } else if (wasSearch && !nowSearch) { + await ftsManager.dropFtsTable(slug); + } + } + return updated; } @@ -256,6 +271,10 @@ export class SchemaRegistry { } } + // Drop the FTS virtual table before dropping the content table + const ftsManager = new FTSManager(this.db); + await ftsManager.dropFtsTable(slug); + // Drop the content table await this.dropContentTable(slug); @@ -368,6 +387,10 @@ export class SchemaRegistry { throw new SchemaError("Failed to create field", "CREATE_FAILED"); } + if (input.searchable) { + await this.rebuildSearchIndex(collectionSlug); + } + return field; } @@ -443,28 +466,23 @@ export class SchemaRegistry { /** * Rebuild the search index for a collection * - * Called when searchable fields change. If search is enabled for the collection, - * this will rebuild the FTS table with the updated field list. + * Called when searchable fields change. Uses supports.includes("search") as + * the single source of truth for whether FTS should be active. */ private async rebuildSearchIndex(collectionSlug: string): Promise { - const ftsManager = new FTSManager(this.db); - - // Check if search is enabled for this collection - const config = await ftsManager.getSearchConfig(collectionSlug); - if (!config?.enabled) { - // Search not enabled, nothing to do + const collection = await this.getCollection(collectionSlug); + if (!collection?.supports.includes("search")) { return; } - // Get current searchable fields + const ftsManager = new FTSManager(this.db); const searchableFields = await ftsManager.getSearchableFields(collectionSlug); if (searchableFields.length === 0) { - // No searchable fields left, disable search - await ftsManager.disableSearch(collectionSlug); + await ftsManager.dropFtsTable(collectionSlug); } else { - // Rebuild the index with updated fields - await ftsManager.rebuildIndex(collectionSlug, searchableFields, config.weights); + const config = await ftsManager.getSearchConfig(collectionSlug); + await ftsManager.rebuildIndex(collectionSlug, searchableFields, config?.weights); } } @@ -480,11 +498,22 @@ export class SchemaRegistry { ); } + // SQLite validates triggers when dropping a column. Drop the FTS table + // (and its triggers) before removing the column so ALTER TABLE succeeds. + if (field.searchable) { + const ftsManager = new FTSManager(this.db); + await ftsManager.dropFtsTable(collectionSlug); + } + // Drop column from content table await this.dropColumn(collectionSlug, fieldSlug); // Delete field record await this.db.deleteFrom("_emdash_fields").where("id", "=", field.id).execute(); + + if (field.searchable) { + await this.rebuildSearchIndex(collectionSlug); + } } /** diff --git a/packages/core/tests/unit/schema/registry.test.ts b/packages/core/tests/unit/schema/registry.test.ts index 218bcfd38..c1aec15ae 100644 --- a/packages/core/tests/unit/schema/registry.test.ts +++ b/packages/core/tests/unit/schema/registry.test.ts @@ -5,6 +5,7 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { runMigrations } from "../../../src/database/migrations/runner.js"; import type { Database as EmDashDatabase } from "../../../src/database/types.js"; import { SchemaRegistry, SchemaError } from "../../../src/schema/registry.js"; +import { FTSManager } from "../../../src/search/fts-manager.js"; describe("SchemaRegistry", () => { let db: Kysely; @@ -372,4 +373,224 @@ describe("SchemaRegistry", () => { expect(field).toBeNull(); }); }); + + describe("FTS integration", () => { + let fts: FTSManager; + + beforeEach(() => { + fts = new FTSManager(db); + }); + + describe("createField", () => { + it("creates FTS table when first searchable field is added to a search-enabled collection", async () => { + await registry.createCollection({ + slug: "posts", + label: "Posts", + supports: ["search"], + }); + + await registry.createField("posts", { + slug: "title", + label: "Title", + type: "string", + searchable: true, + }); + + expect(await fts.ftsTableExists("posts")).toBe(true); + }); + + it("does not create FTS table when searchable field is added to collection without search support", async () => { + await registry.createCollection({ + slug: "posts", + label: "Posts", + supports: ["drafts"], + }); + + await registry.createField("posts", { + slug: "title", + label: "Title", + type: "string", + searchable: true, + }); + + expect(await fts.ftsTableExists("posts")).toBe(false); + }); + + it("does not create FTS table when non-searchable field is added to search-enabled collection", async () => { + await registry.createCollection({ + slug: "posts", + label: "Posts", + supports: ["search"], + }); + + await registry.createField("posts", { + slug: "title", + label: "Title", + type: "string", + searchable: false, + }); + + expect(await fts.ftsTableExists("posts")).toBe(false); + }); + + it("rebuilds FTS table to include a second searchable field", async () => { + await registry.createCollection({ + slug: "posts", + label: "Posts", + supports: ["search"], + }); + await registry.createField("posts", { + slug: "title", + label: "Title", + type: "string", + searchable: true, + }); + await registry.createField("posts", { + slug: "body", + label: "Body", + type: "text", + searchable: true, + }); + + expect(await fts.ftsTableExists("posts")).toBe(true); + const fields = await fts.getSearchableFields("posts"); + expect(fields).toContain("title"); + expect(fields).toContain("body"); + }); + }); + + describe("deleteField", () => { + beforeEach(async () => { + await registry.createCollection({ + slug: "posts", + label: "Posts", + supports: ["search"], + }); + await registry.createField("posts", { + slug: "title", + label: "Title", + type: "string", + searchable: true, + }); + await registry.createField("posts", { + slug: "body", + label: "Body", + type: "text", + searchable: true, + }); + }); + + it("rebuilds FTS table after deleting one of multiple searchable fields", async () => { + expect(await fts.ftsTableExists("posts")).toBe(true); + + await registry.deleteField("posts", "body"); + + expect(await fts.ftsTableExists("posts")).toBe(true); + const fields = await fts.getSearchableFields("posts"); + expect(fields).toEqual(["title"]); + }); + + it("drops FTS table when the last searchable field is deleted", async () => { + expect(await fts.ftsTableExists("posts")).toBe(true); + + await registry.deleteField("posts", "title"); + expect(await fts.ftsTableExists("posts")).toBe(true); // body still searchable + + await registry.deleteField("posts", "body"); + expect(await fts.ftsTableExists("posts")).toBe(false); // nothing searchable left + }); + + it("does not affect FTS table when a non-searchable field is deleted", async () => { + await registry.createField("posts", { + slug: "excerpt", + label: "Excerpt", + type: "text", + searchable: false, + }); + + await registry.deleteField("posts", "excerpt"); + + expect(await fts.ftsTableExists("posts")).toBe(true); + }); + }); + + describe("deleteCollection", () => { + it("drops FTS table when deleting a search-enabled collection", async () => { + await registry.createCollection({ + slug: "posts", + label: "Posts", + supports: ["search"], + }); + await registry.createField("posts", { + slug: "title", + label: "Title", + type: "string", + searchable: true, + }); + expect(await fts.ftsTableExists("posts")).toBe(true); + + await registry.deleteCollection("posts", { force: true }); + + expect(await fts.ftsTableExists("posts")).toBe(false); + }); + }); + + describe("updateCollection supports", () => { + it("creates FTS table when search is added to supports and searchable fields exist", async () => { + await registry.createCollection({ + slug: "posts", + label: "Posts", + supports: ["drafts"], + }); + await registry.createField("posts", { + slug: "title", + label: "Title", + type: "string", + searchable: true, + }); + expect(await fts.ftsTableExists("posts")).toBe(false); + + await registry.updateCollection("posts", { supports: ["drafts", "search"] }); + + expect(await fts.ftsTableExists("posts")).toBe(true); + }); + + it("drops FTS table when search is removed from supports", async () => { + await registry.createCollection({ + slug: "posts", + label: "Posts", + supports: ["search"], + }); + await registry.createField("posts", { + slug: "title", + label: "Title", + type: "string", + searchable: true, + }); + expect(await fts.ftsTableExists("posts")).toBe(true); + + await registry.updateCollection("posts", { supports: ["drafts"] }); + + expect(await fts.ftsTableExists("posts")).toBe(false); + }); + + it("does not create FTS table when search is added to supports but no searchable fields exist", async () => { + await registry.createCollection({ + slug: "posts", + label: "Posts", + supports: ["drafts"], + }); + await registry.createField("posts", { + slug: "title", + label: "Title", + type: "string", + searchable: false, + }); + + await registry.updateCollection("posts", { supports: ["drafts", "search"] }); + + expect(await fts.ftsTableExists("posts")).toBe(false); + }); + }); + }); });