import Database from "better-sqlite3"; import { Kysely, SqliteDialect, sql } from "kysely"; import { describe, it, expect, beforeEach, afterEach, vi } 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; let registry: SchemaRegistry; beforeEach(async () => { // Create in-memory database const sqlite = new Database(":memory:"); db = new Kysely({ dialect: new SqliteDialect({ database: sqlite }), }); // Run migrations await runMigrations(db); // Create registry registry = new SchemaRegistry(db); }); afterEach(async () => { await db.destroy(); }); describe("Collection Operations", () => { it("should create a collection", async () => { const collection = await registry.createCollection({ slug: "posts", label: "Blog Posts", labelSingular: "Post", supports: ["drafts", "revisions"], }); expect(collection.slug).toBe("posts"); expect(collection.label).toBe("Blog Posts"); expect(collection.labelSingular).toBe("Post"); expect(collection.supports).toEqual(["drafts", "revisions"]); expect(collection.source).toBe("manual"); expect(collection.id).toBeDefined(); }); it("F14: defaults supports to ['drafts', 'revisions'] when undefined", async () => { const collection = await registry.createCollection({ slug: "default_supports", label: "Default Supports", // supports omitted entirely }); expect(collection.supports.toSorted()).toEqual(["drafts", "revisions"].toSorted()); }); it("F14: preserves explicit empty supports array (opt-out)", async () => { const collection = await registry.createCollection({ slug: "no_supports", label: "No Supports", supports: [], }); expect(collection.supports).toEqual([]); }); it("should create the content table when creating a collection", async () => { await registry.createCollection({ slug: "articles", label: "Articles", }); // Verify table exists by inserting a row const result = await db .insertInto("ec_articles" as any) .values({ id: "test-id", slug: "test-slug", status: "draft", }) .execute(); expect(result).toBeDefined(); }); it("should list collections", async () => { await registry.createCollection({ slug: "posts", label: "Posts" }); await registry.createCollection({ slug: "pages", label: "Pages" }); const collections = await registry.listCollections(); expect(collections).toHaveLength(2); expect(collections.map((c) => c.slug)).toEqual(["pages", "posts"]); // sorted }); it("should get a collection by slug", async () => { await registry.createCollection({ slug: "products", label: "Products", description: "Store products", }); const collection = await registry.getCollection("products"); expect(collection).not.toBeNull(); expect(collection?.slug).toBe("products"); expect(collection?.description).toBe("Store products"); }); it("should return null for non-existent collection", async () => { const collection = await registry.getCollection("nonexistent"); expect(collection).toBeNull(); }); it("should update a collection", async () => { await registry.createCollection({ slug: "posts", label: "Posts" }); const updated = await registry.updateCollection("posts", { label: "Blog Posts", description: "All blog posts", supports: ["drafts"], }); expect(updated.label).toBe("Blog Posts"); expect(updated.description).toBe("All blog posts"); expect(updated.supports).toEqual(["drafts"]); }); it("should throw when updating non-existent collection", async () => { await expect(registry.updateCollection("nonexistent", { label: "Test" })).rejects.toThrow( SchemaError, ); }); it("should delete a collection", async () => { await registry.createCollection({ slug: "temp", label: "Temp" }); await registry.deleteCollection("temp"); const collection = await registry.getCollection("temp"); expect(collection).toBeNull(); }); it("should throw when creating duplicate collection", async () => { await registry.createCollection({ slug: "posts", label: "Posts" }); await expect(registry.createCollection({ slug: "posts", label: "Posts 2" })).rejects.toThrow( SchemaError, ); }); it("should reject reserved collection slugs", async () => { await expect( registry.createCollection({ slug: "content", label: "Content" }), ).rejects.toThrow(SchemaError); await expect(registry.createCollection({ slug: "users", label: "Users" })).rejects.toThrow( SchemaError, ); }); it("should validate collection slug format", async () => { await expect(registry.createCollection({ slug: "My Posts", label: "Posts" })).rejects.toThrow( SchemaError, ); await expect(registry.createCollection({ slug: "123posts", label: "Posts" })).rejects.toThrow( SchemaError, ); await expect( registry.createCollection({ slug: "posts-here", label: "Posts" }), ).rejects.toThrow(SchemaError); }); }); describe("Field Operations", () => { beforeEach(async () => { await registry.createCollection({ slug: "posts", label: "Posts" }); }); it("should create a field", async () => { const field = await registry.createField("posts", { slug: "title", label: "Title", type: "string", required: true, }); expect(field.slug).toBe("title"); expect(field.label).toBe("Title"); expect(field.type).toBe("string"); expect(field.columnType).toBe("TEXT"); expect(field.required).toBe(true); }); it("should add column to content table when creating field", async () => { await registry.createField("posts", { slug: "title", label: "Title", type: "string", }); // Verify column exists by inserting a row with the field await db .insertInto("ec_posts" as any) .values({ id: "test-id", title: "Test Title", }) .execute(); const row = await db .selectFrom("ec_posts" as any) .selectAll() .executeTakeFirst(); expect((row as any).title).toBe("Test Title"); }); it("should list fields for a collection", async () => { const collection = await registry.getCollection("posts"); await registry.createField("posts", { slug: "title", label: "Title", type: "string", }); await registry.createField("posts", { slug: "content", label: "Content", type: "portableText", }); const fields = await registry.listFields(collection!.id); expect(fields).toHaveLength(2); expect(fields[0].slug).toBe("title"); expect(fields[1].slug).toBe("content"); }); it("should get a field by slug", async () => { await registry.createField("posts", { slug: "title", label: "Title", type: "string", validation: { minLength: 1, maxLength: 100 }, }); const field = await registry.getField("posts", "title"); expect(field).not.toBeNull(); expect(field?.validation).toEqual({ minLength: 1, maxLength: 100 }); }); it("should update a field", async () => { await registry.createField("posts", { slug: "title", label: "Title", type: "string", }); const updated = await registry.updateField("posts", "title", { label: "Post Title", required: true, widget: "text", }); expect(updated.label).toBe("Post Title"); expect(updated.required).toBe(true); expect(updated.widget).toBe("text"); }); it("should delete a field", async () => { await registry.createField("posts", { slug: "temp_field", label: "Temp", type: "string", }); await registry.deleteField("posts", "temp_field"); const field = await registry.getField("posts", "temp_field"); expect(field).toBeNull(); }); it("should reject reserved field slugs", async () => { await expect( registry.createField("posts", { slug: "id", label: "ID", type: "string", }), ).rejects.toThrow(SchemaError); await expect( registry.createField("posts", { slug: "created_at", label: "Created", type: "datetime", }), ).rejects.toThrow(SchemaError); }); it("should map field types to correct column types", async () => { const testCases: Array<{ type: any; slug: string; expected: string }> = [ { type: "string", slug: "f_string", expected: "TEXT" }, { type: "text", slug: "f_text", expected: "TEXT" }, { type: "number", slug: "f_number", expected: "REAL" }, { type: "integer", slug: "f_integer", expected: "INTEGER" }, { type: "boolean", slug: "f_boolean", expected: "INTEGER" }, { type: "datetime", slug: "f_datetime", expected: "TEXT" }, { type: "portableText", slug: "f_portable", expected: "JSON" }, { type: "json", slug: "f_json", expected: "JSON" }, { type: "image", slug: "f_image", expected: "TEXT" }, { type: "reference", slug: "f_reference", expected: "TEXT" }, ]; for (const { type, slug, expected } of testCases) { const field = await registry.createField("posts", { slug, label: type, type, }); expect(field.columnType).toBe(expected); } }); it("should reorder fields", async () => { await registry.createField("posts", { slug: "title", label: "Title", type: "string", }); await registry.createField("posts", { slug: "content", label: "Content", type: "portableText", }); await registry.createField("posts", { slug: "author", label: "Author", type: "reference", }); await registry.reorderFields("posts", ["author", "title", "content"]); const collection = await registry.getCollection("posts"); const fields = await registry.listFields(collection!.id); expect(fields[0].slug).toBe("author"); expect(fields[1].slug).toBe("title"); expect(fields[2].slug).toBe("content"); }); }); describe("Collection with Fields", () => { it("should get collection with all fields", async () => { await registry.createCollection({ slug: "posts", label: "Posts" }); await registry.createField("posts", { slug: "title", label: "Title", type: "string", }); await registry.createField("posts", { slug: "content", label: "Content", type: "portableText", }); const collection = await registry.getCollectionWithFields("posts"); expect(collection).not.toBeNull(); expect(collection?.slug).toBe("posts"); expect(collection?.fields).toHaveLength(2); expect(collection?.fields[0].slug).toBe("title"); expect(collection?.fields[1].slug).toBe("content"); }); it("should cascade delete fields when deleting collection", async () => { await registry.createCollection({ slug: "temp", label: "Temp" }); await registry.createField("temp", { slug: "field1", label: "Field 1", type: "string", }); await registry.deleteCollection("temp"); // Fields should be gone (cascade delete) const field = await registry.getField("temp", "field1"); expect(field).toBeNull(); }); }); describe("Search (FTS) Integration", () => { let ftsManager: FTSManager; beforeEach(() => { ftsManager = new FTSManager(db); }); it("does not auto-enable FTS when adding a searchable field", async () => { await registry.createCollection({ slug: "articles", label: "Articles", supports: ["search"], }); await registry.createField("articles", { slug: "title", label: "Title", type: "string", searchable: true, }); expect(await ftsManager.ftsTableExists("articles")).toBe(false); }); it("does not auto-enable FTS when adding search support to a collection", async () => { await registry.createCollection({ slug: "articles", label: "Articles", supports: ["drafts"], }); await registry.createField("articles", { slug: "title", label: "Title", type: "string", searchable: true, }); expect(await ftsManager.ftsTableExists("articles")).toBe(false); await registry.updateCollection("articles", { supports: ["drafts", "search"] }); expect(await ftsManager.ftsTableExists("articles")).toBe(false); }); it("disables FTS when search support is removed from a collection", async () => { await registry.createCollection({ slug: "articles", label: "Articles", supports: ["search"], }); await registry.createField("articles", { slug: "title", label: "Title", type: "string", searchable: true, }); await ftsManager.enableSearch("articles"); expect(await ftsManager.ftsTableExists("articles")).toBe(true); await registry.updateCollection("articles", { supports: ["drafts"] }); expect(await ftsManager.ftsTableExists("articles")).toBe(false); }); it("rebuilds FTS table to include a new searchable field when collection already has search enabled", async () => { await registry.createCollection({ slug: "articles", label: "Articles", supports: ["search"], }); await registry.createField("articles", { slug: "title", label: "Title", type: "string", searchable: true, }); await ftsManager.enableSearch("articles"); expect(await ftsManager.ftsTableExists("articles")).toBe(true); await registry.createField("articles", { slug: "body", label: "Body", type: "text", searchable: true, }); await expect( sql`SELECT body FROM "_emdash_fts_articles" LIMIT 0`.execute(db), ).resolves.toBeDefined(); }); it("deletes a searchable field from a search-enabled collection without error", async () => { await registry.createCollection({ slug: "articles", label: "Articles", supports: ["search"], }); await registry.createField("articles", { slug: "title", label: "Title", type: "string", searchable: true, }); await registry.createField("articles", { slug: "body", label: "Body", type: "text", searchable: true, }); await ftsManager.enableSearch("articles"); await expect(registry.deleteField("articles", "body")).resolves.toBeUndefined(); expect(await ftsManager.ftsTableExists("articles")).toBe(true); await expect( sql`SELECT title FROM "_emdash_fts_articles" LIMIT 0`.execute(db), ).resolves.toBeDefined(); }); it("drops FTS table when deleting a search-enabled collection", async () => { await registry.createCollection({ slug: "articles", label: "Articles", supports: ["search"], }); await registry.createField("articles", { slug: "title", label: "Title", type: "string", searchable: true, }); await ftsManager.enableSearch("articles"); expect(await ftsManager.ftsTableExists("articles")).toBe(true); await registry.deleteCollection("articles"); expect(await ftsManager.ftsTableExists("articles")).toBe(false); }); it("disables FTS when the last searchable field is deleted", async () => { await registry.createCollection({ slug: "articles", label: "Articles", supports: ["search"], }); await registry.createField("articles", { slug: "title", label: "Title", type: "string", searchable: true, }); await ftsManager.enableSearch("articles"); expect(await ftsManager.ftsTableExists("articles")).toBe(true); await registry.deleteField("articles", "title"); expect(await ftsManager.ftsTableExists("articles")).toBe(false); }); it("does not create FTS table when collection supports search but has no searchable fields", async () => { await registry.createCollection({ slug: "articles", label: "Articles", supports: ["search"], }); await registry.createField("articles", { slug: "title", label: "Title", type: "string", searchable: false, }); expect(await ftsManager.ftsTableExists("articles")).toBe(false); }); it("preserves weights in config when search support is toggled off", async () => { await registry.createCollection({ slug: "articles", label: "Articles", supports: ["search"], }); await registry.createField("articles", { slug: "title", label: "Title", type: "string", searchable: true, }); await ftsManager.enableSearch("articles", { weights: { title: 10 } }); const initialConfig = await ftsManager.getSearchConfig("articles"); expect(initialConfig?.weights).toEqual({ title: 10 }); await registry.updateCollection("articles", { supports: ["drafts"] }); expect(await ftsManager.ftsTableExists("articles")).toBe(false); const finalConfig = await ftsManager.getSearchConfig("articles"); expect(finalConfig?.weights).toEqual({ title: 10 }); }); }); describe("atomicity: rollback on FTS sync failure", () => { afterEach(() => { vi.restoreAllMocks(); }); it("rolls back updateCollection when FTS disable fails", async () => { await registry.createCollection({ slug: "articles", label: "Articles", supports: ["search"], }); await registry.createField("articles", { slug: "title", label: "Title", type: "string", searchable: true, }); const ftsManager = new FTSManager(db); await ftsManager.enableSearch("articles"); vi.spyOn(FTSManager.prototype, "disableSearch").mockRejectedValueOnce( new Error("FTS sync sabotaged"), ); await expect( registry.updateCollection("articles", { supports: ["drafts"] }), ).rejects.toThrow(); const collection = await registry.getCollection("articles"); expect(collection?.supports).toContain("search"); }); it("rolls back updateField when FTS rebuild fails", async () => { await registry.createCollection({ slug: "articles", label: "Articles", supports: ["search"], }); await registry.createField("articles", { slug: "title", label: "Title", type: "string", searchable: true, }); const ftsManager = new FTSManager(db); await ftsManager.enableSearch("articles"); vi.spyOn(FTSManager.prototype, "disableSearch").mockRejectedValueOnce( new Error("FTS sync sabotaged"), ); await expect( registry.updateField("articles", "title", { searchable: false }), ).rejects.toThrow(); const field = await registry.getField("articles", "title"); expect(field?.searchable).toBe(true); }); it("rolls back deleteField when FTS rebuild fails", async () => { await registry.createCollection({ slug: "articles", label: "Articles", supports: ["search"], }); await registry.createField("articles", { slug: "title", label: "Title", type: "string", searchable: true, }); await registry.createField("articles", { slug: "body", label: "Body", type: "text", searchable: true, }); const ftsManager = new FTSManager(db); await ftsManager.enableSearch("articles"); vi.spyOn(FTSManager.prototype, "rebuildIndex").mockRejectedValueOnce( new Error("FTS sync sabotaged"), ); await expect(registry.deleteField("articles", "body")).rejects.toThrow(); const field = await registry.getField("articles", "body"); expect(field).not.toBeNull(); }); it("rolls back createField when FTS rebuild fails", async () => { await registry.createCollection({ slug: "articles", label: "Articles", supports: ["search"], }); await registry.createField("articles", { slug: "title", label: "Title", type: "string", searchable: true, }); const ftsManager = new FTSManager(db); await ftsManager.enableSearch("articles"); vi.spyOn(FTSManager.prototype, "rebuildIndex").mockRejectedValueOnce( new Error("FTS sync sabotaged"), ); await expect( registry.createField("articles", { slug: "body", label: "Body", type: "text", searchable: true, }), ).rejects.toThrow(); const field = await registry.getField("articles", "body"); expect(field).toBeNull(); }); }); });