Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/chubby-ears-joke.md
Original file line number Diff line number Diff line change
@@ -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.
55 changes: 42 additions & 13 deletions packages/core/src/schema/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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);

Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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<void> {
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);
}
}

Expand All @@ -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);
}
}

/**
Expand Down
221 changes: 221 additions & 0 deletions packages/core/tests/unit/schema/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<EmDashDatabase>;
Expand Down Expand Up @@ -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);
});
});
});
});
Loading