import type { Kysely } from "kysely"; import { sql } from "kysely"; import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { PluginStorageRepository } from "../../../src/database/repositories/plugin-storage.js"; import type { Database } from "../../../src/database/types.js"; import { createStorageIndexes, removeOrphanedIndexes, syncStorageIndexes, removeAllPluginIndexes, getPluginIndexStatus, } from "../../../src/plugins/storage-indexes.js"; import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js"; const UNIQUE_CONSTRAINT_PATTERN = /UNIQUE constraint failed/; describe("Plugin Storage Indexes Integration", () => { let db: Kysely; beforeEach(async () => { db = await setupTestDatabase(); }); afterEach(async () => { await teardownTestDatabase(db); }); describe("createStorageIndexes", () => { it("should create single-field index", async () => { const result = await createStorageIndexes(db, "my-plugin", "events", ["eventType"]); expect(result.created).toContain("idx_plugin_my-plugin_events_eventType"); expect(result.errors).toHaveLength(0); }); it("should create composite index", async () => { const result = await createStorageIndexes(db, "my-plugin", "events", [ ["status", "createdAt"], ]); expect(result.created).toContain("idx_plugin_my-plugin_events_status_createdAt"); expect(result.errors).toHaveLength(0); }); it("should create multiple indexes", async () => { const result = await createStorageIndexes(db, "my-plugin", "events", [ "eventType", "userId", ["status", "timestamp"], ]); expect(result.created).toHaveLength(3); expect(result.errors).toHaveLength(0); }); it("should track indexes in _plugin_indexes table", async () => { await createStorageIndexes(db, "my-plugin", "events", ["eventType", "userId"]); const indexes = await db .selectFrom("_plugin_indexes") .selectAll() .where("plugin_id", "=", "my-plugin") .execute(); expect(indexes).toHaveLength(2); expect(indexes.map((i) => JSON.parse(i.fields))).toContainEqual(["eventType"]); expect(indexes.map((i) => JSON.parse(i.fields))).toContainEqual(["userId"]); }); it("should be idempotent", async () => { await createStorageIndexes(db, "my-plugin", "events", ["eventType"]); const result = await createStorageIndexes(db, "my-plugin", "events", ["eventType"]); // Should still succeed expect(result.errors).toHaveLength(0); // Should not duplicate tracking records const indexes = await db .selectFrom("_plugin_indexes") .selectAll() .where("plugin_id", "=", "my-plugin") .execute(); expect(indexes).toHaveLength(1); }); }); describe("removeOrphanedIndexes", () => { it("should remove indexes no longer in declaration", async () => { // Create initial indexes await createStorageIndexes(db, "my-plugin", "events", ["eventType", "userId", "status"]); // Remove one const result = await removeOrphanedIndexes(db, "my-plugin", "events", [ "eventType", "userId", ]); expect(result.removed).toContain("idx_plugin_my-plugin_events_status"); expect(result.errors).toHaveLength(0); }); it("should keep indexes that are still declared", async () => { await createStorageIndexes(db, "my-plugin", "events", ["eventType", "userId"]); const result = await removeOrphanedIndexes(db, "my-plugin", "events", [ "eventType", "userId", ]); expect(result.removed).toHaveLength(0); }); it("should update tracking table", async () => { await createStorageIndexes(db, "my-plugin", "events", ["eventType", "status"]); await removeOrphanedIndexes(db, "my-plugin", "events", ["eventType"]); const indexes = await db .selectFrom("_plugin_indexes") .selectAll() .where("plugin_id", "=", "my-plugin") .execute(); expect(indexes).toHaveLength(1); expect(JSON.parse(indexes[0].fields)).toEqual(["eventType"]); }); }); describe("syncStorageIndexes", () => { it("should create new and remove old indexes in one call", async () => { // Initial state await createStorageIndexes(db, "my-plugin", "events", ["eventType", "oldField"]); // Sync to new state const result = await syncStorageIndexes(db, "my-plugin", "events", ["eventType", "newField"]); expect(result.created).toContain("idx_plugin_my-plugin_events_newField"); expect(result.removed).toContain("idx_plugin_my-plugin_events_oldField"); const status = await getPluginIndexStatus(db, "my-plugin"); const fields = status.map((s) => s.fields); expect(fields).toContainEqual(["eventType"]); expect(fields).toContainEqual(["newField"]); expect(fields).not.toContainEqual(["oldField"]); }); }); describe("removeAllPluginIndexes", () => { it("should remove all indexes for a plugin", async () => { await createStorageIndexes(db, "my-plugin", "events", ["eventType", "userId"]); await createStorageIndexes(db, "my-plugin", "cache", ["key", "expiresAt"]); const result = await removeAllPluginIndexes(db, "my-plugin"); expect(result.removed).toHaveLength(4); expect(result.errors).toHaveLength(0); const remaining = await db .selectFrom("_plugin_indexes") .selectAll() .where("plugin_id", "=", "my-plugin") .execute(); expect(remaining).toHaveLength(0); }); it("should not affect other plugins", async () => { await createStorageIndexes(db, "plugin1", "events", ["eventType"]); await createStorageIndexes(db, "plugin2", "events", ["eventType"]); await removeAllPluginIndexes(db, "plugin1"); const plugin2Indexes = await db .selectFrom("_plugin_indexes") .selectAll() .where("plugin_id", "=", "plugin2") .execute(); expect(plugin2Indexes).toHaveLength(1); }); }); describe("getPluginIndexStatus", () => { it("should return all indexes for a plugin", async () => { await createStorageIndexes(db, "my-plugin", "events", ["eventType", ["status", "timestamp"]]); await createStorageIndexes(db, "my-plugin", "cache", ["key"]); const status = await getPluginIndexStatus(db, "my-plugin"); expect(status).toHaveLength(3); expect(status).toContainEqual( expect.objectContaining({ collection: "events", fields: ["eventType"], }), ); expect(status).toContainEqual( expect.objectContaining({ collection: "events", fields: ["status", "timestamp"], }), ); expect(status).toContainEqual( expect.objectContaining({ collection: "cache", fields: ["key"], }), ); }); it("should return empty array for plugin with no indexes", async () => { const status = await getPluginIndexStatus(db, "nonexistent-plugin"); expect(status).toEqual([]); }); }); describe("query performance with indexes", () => { it("should efficiently query using indexed fields", async () => { const pluginId = "perf-test"; const collection = "events"; // Create index first await createStorageIndexes(db, pluginId, collection, ["eventType"]); // Create repository with the indexed field const repo = new PluginStorageRepository<{ eventType: string }>(db, pluginId, collection, [ "eventType", ]); // Insert test data const items = Array.from({ length: 100 }, (_, i) => ({ id: `event-${i}`, data: { eventType: i % 2 === 0 ? "pageview" : "click" }, })); await repo.putMany(items); // Query should work and use the index const result = await repo.query({ where: { eventType: "pageview" }, }); expect(result.items).toHaveLength(50); expect(result.items.every((i) => i.data.eventType === "pageview")).toBe(true); }); }); describe("index verification", () => { it("should create actual SQLite index", async () => { await createStorageIndexes(db, "my-plugin", "events", ["eventType"]); // Query SQLite's index list const indexes = await sql<{ name: string }>` SELECT name FROM sqlite_master WHERE type = 'index' AND name LIKE 'idx_plugin_%' `.execute(db); expect(indexes.rows.map((r) => r.name)).toContain("idx_plugin_my-plugin_events_eventType"); }); it("should drop actual SQLite index on removal", async () => { await createStorageIndexes(db, "my-plugin", "events", ["eventType"]); await removeAllPluginIndexes(db, "my-plugin"); const indexes = await sql<{ name: string }>` SELECT name FROM sqlite_master WHERE type = 'index' AND name LIKE 'idx_plugin_my-plugin_%' `.execute(db); expect(indexes.rows).toHaveLength(0); }); }); describe("unique indexes", () => { it("should create a unique index", async () => { const result = await createStorageIndexes(db, "my-plugin", "forms", [], { uniqueIndexes: ["slug"], }); expect(result.created).toContain("uidx_plugin_my-plugin_forms_slug"); expect(result.errors).toHaveLength(0); // Verify it's actually a UNIQUE index in SQLite const indexSql = await sql<{ sql: string }>` SELECT sql FROM sqlite_master WHERE type = 'index' AND name = 'uidx_plugin_my-plugin_forms_slug' `.execute(db); expect(indexSql.rows).toHaveLength(1); expect(indexSql.rows[0].sql).toContain("UNIQUE"); }); it("should enforce uniqueness on insert", async () => { await createStorageIndexes(db, "my-plugin", "forms", [], { uniqueIndexes: ["slug"], }); const repo = new PluginStorageRepository<{ slug: string; name: string }>( db, "my-plugin", "forms", ["slug"], ); await repo.put("form-1", { slug: "contact", name: "Contact" }); // Second insert with a different ID but same slug should fail await expect(repo.put("form-2", { slug: "contact", name: "Contact Copy" })).rejects.toThrow( UNIQUE_CONSTRAINT_PATTERN, ); }); it("should allow updating the same document", async () => { await createStorageIndexes(db, "my-plugin", "forms", [], { uniqueIndexes: ["slug"], }); const repo = new PluginStorageRepository<{ slug: string; name: string }>( db, "my-plugin", "forms", ["slug"], ); await repo.put("form-1", { slug: "contact", name: "Contact" }); // Updating the same ID should succeed (upsert) await repo.put("form-1", { slug: "contact", name: "Contact Updated" }); const result = await repo.get("form-1"); expect(result?.name).toBe("Contact Updated"); }); it("should allow different slugs across different collections", async () => { await createStorageIndexes(db, "my-plugin", "forms", [], { uniqueIndexes: ["slug"], }); await createStorageIndexes(db, "my-plugin", "templates", [], { uniqueIndexes: ["slug"], }); const formsRepo = new PluginStorageRepository<{ slug: string }>(db, "my-plugin", "forms", [ "slug", ]); const templatesRepo = new PluginStorageRepository<{ slug: string }>( db, "my-plugin", "templates", ["slug"], ); // Same slug in different collections should work (partial index scoped by collection) await formsRepo.put("form-1", { slug: "contact" }); await templatesRepo.put("tmpl-1", { slug: "contact" }); expect(await formsRepo.get("form-1")).toEqual({ slug: "contact" }); expect(await templatesRepo.get("tmpl-1")).toEqual({ slug: "contact" }); }); it("should include unique index fields in queryable fields", async () => { await createStorageIndexes(db, "my-plugin", "forms", ["status"], { uniqueIndexes: ["slug"], }); const repo = new PluginStorageRepository<{ slug: string; status: string }>( db, "my-plugin", "forms", ["status", "slug"], ); await repo.put("form-1", { slug: "contact", status: "active" }); await repo.put("form-2", { slug: "feedback", status: "active" }); // Query by unique field should work const result = await repo.query({ where: { slug: "contact" } }); expect(result.items).toHaveLength(1); expect(result.items[0].data.slug).toBe("contact"); }); }); });