/// /* eslint-disable @convex-dev/no-filter-in-query */ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { type TestConvex } from "convex-test"; import schema from "./schema.js"; import { api, internal } from "./_generated/api.js"; import { initConvexTest } from "./setup.test.js"; import type { Id } from "./_generated/dataModel.js"; type ConvexTest = TestConvex; describe("entries", () => { async function setupTestNamespace(t: ConvexTest, filterNames: string[] = []) { const namespace = await t.mutation(api.namespaces.getOrCreate, { namespace: "test-namespace", status: "ready", modelId: "test-model", dimension: 128, filterNames, }); return namespace.namespaceId; } beforeEach(async () => { vi.useFakeTimers(); }); afterEach(() => { vi.useRealTimers(); }); function testEntryArgs(namespaceId: Id<"namespaces">, key = "test-entry") { return { namespaceId, key, importance: 0.5, filterValues: [], contentHash: "hash123", title: "Test Entry", }; } test("add creates a new entry when none exists", async () => { const t = initConvexTest(); const namespaceId = await setupTestNamespace(t); const entry = testEntryArgs(namespaceId); const result = await t.mutation(api.entries.add, { entry, allChunks: [], }); expect(result.created).toBe(true); expect(result.status).toBe("ready"); expect(result.entryId).toBeDefined(); // Verify the entry was actually created const createdDoc = await t.run(async (ctx) => { return ctx.db.get("entries", result.entryId); }); expect(createdDoc).toBeDefined(); expect(createdDoc!.key).toBe(entry.key); expect(createdDoc!.version).toBe(0); expect(createdDoc!.status.kind).toBe("ready"); }); test("add returns existing entry when adding identical content", async () => { const t = initConvexTest(); const namespaceId = await setupTestNamespace(t); const entry = testEntryArgs(namespaceId); // First add const firstResult = await t.mutation(api.entries.add, { entry, allChunks: [], }); expect(firstResult.created).toBe(true); expect(firstResult.status).toBe("ready"); // Second add with identical content const secondResult = await t.mutation(api.entries.add, { entry, allChunks: [], }); expect(secondResult.created).toBe(false); expect(secondResult.status).toBe("ready"); expect(secondResult.entryId).toBe(firstResult.entryId); // Verify no new entry was created const allDocs = await t.run(async (ctx) => { return ctx.db .query("entries") .filter((q) => q.and( q.eq(q.field("namespaceId"), namespaceId), q.eq(q.field("key"), entry.key), ), ) .collect(); }); expect(allDocs).toHaveLength(1); expect(allDocs[0]._id).toBe(firstResult.entryId); }); test("add creates new version when content hash changes", async () => { const t = initConvexTest(); const namespaceId = await setupTestNamespace(t); const entry = testEntryArgs(namespaceId); // First add const firstResult = await t.mutation(api.entries.add, { entry, allChunks: [], }); expect(firstResult.created).toBe(true); // Second add with different content hash const modifiedEntry = { ...entry, contentHash: "hash456", // Different hash }; const secondResult = await t.mutation(api.entries.add, { entry: modifiedEntry, allChunks: [], }); expect(secondResult.created).toBe(true); expect(secondResult.entryId).not.toBe(firstResult.entryId); expect(secondResult.status).toBe("pending"); // Verify both entries exist with different versions const allDocs = await t.run(async (ctx) => { return ctx.db .query("entries") .filter((q) => q.and( q.eq(q.field("namespaceId"), namespaceId), q.eq(q.field("key"), entry.key), ), ) .collect(); }); expect(allDocs).toHaveLength(2); const versions = allDocs.map((entry) => entry.version).sort(); expect(versions).toEqual([0, 1]); }); test("add creates new version when importance changes", async () => { const t = initConvexTest(); const namespaceId = await setupTestNamespace(t); const entry = testEntryArgs(namespaceId); // First add const firstResult = await t.mutation(api.entries.add, { entry, allChunks: [], }); expect(firstResult.status).toBe("ready"); const first = await t.run(async (ctx) => { return ctx.db.get("entries", firstResult.entryId); })!; expect(first?.version).toBe(0); expect(first?.status.kind).toBe("ready"); // Second add with different importance const modifiedEntry = { ...entry, importance: 0.8, // Changed from 0.5 }; const secondResult = await t.mutation(api.entries.add, { entry: modifiedEntry, allChunks: [], }); expect(secondResult.created).toBe(true); expect(secondResult.entryId).not.toBe(firstResult.entryId); const second = await t.run(async (ctx) => { return ctx.db.get("entries", secondResult.entryId); })!; expect(second?.version).toBe(1); expect(second?.status.kind).toBe("pending"); expect(secondResult.status).toBe("pending"); // Verify new version was created const newDoc = await t.run(async (ctx) => { return ctx.db.get("entries", secondResult.entryId); }); expect(newDoc!.version).toBe(1); expect(newDoc!.importance).toBe(0.8); }); test("add creates new version when filter values change", async () => { const t = initConvexTest(); const namespaceId = await setupTestNamespace(t, ["category"]); // Add filter name const entry = testEntryArgs(namespaceId); // First add const firstResult = await t.mutation(api.entries.add, { entry, allChunks: [], }); expect(firstResult.status).toBe("ready"); // Second add with different filter values const modifiedEntry = { ...entry, filterValues: [{ name: "category", value: "test" }], }; const secondResult = await t.mutation(api.entries.add, { entry: modifiedEntry, allChunks: [], }); expect(secondResult.created).toBe(true); expect(secondResult.entryId).not.toBe(firstResult.entryId); expect(secondResult.status).toBe("pending"); // Verify new version was created with correct filter values const newDoc = await t.run(async (ctx) => { return ctx.db.get("entries", secondResult.entryId); }); expect(newDoc!.version).toBe(1); expect(newDoc!.filterValues).toHaveLength(1); expect(newDoc!.filterValues[0].name).toBe("category"); expect(newDoc!.filterValues[0].value).toBe("test"); }); test("add without allChunks creates pending entry", async () => { const t = initConvexTest(); const namespaceId = await setupTestNamespace(t); const entry = testEntryArgs(namespaceId); const result = await t.mutation(api.entries.add, { entry, // No allChunks provided }); expect(result.created).toBe(true); expect(result.status).toBe("pending"); // Verify the entry was created with pending status const createdDoc = await t.run(async (ctx) => { return ctx.db.get("entries", result.entryId); }); expect(createdDoc!.status.kind).toBe("pending"); }); test("multiple entries with different keys can coexist", async () => { const t = initConvexTest(); const namespaceId = await setupTestNamespace(t); const entry1 = testEntryArgs(namespaceId, "doc1"); const entry2 = testEntryArgs(namespaceId, "doc2"); const result1 = await t.mutation(api.entries.add, { entry: entry1, allChunks: [], }); const result2 = await t.mutation(api.entries.add, { entry: entry2, allChunks: [], }); expect(result1.created).toBe(true); expect(result2.created).toBe(true); expect(result1.entryId).not.toBe(result2.entryId); expect(result1.status).toBe("ready"); expect(result2.status).toBe("ready"); // Verify both entries exist const allDocs = await t.run(async (ctx) => { return ctx.db .query("entries") .filter((q) => q.eq(q.field("namespaceId"), namespaceId)) .collect(); }); expect(allDocs).toHaveLength(2); const keys = allDocs.map((entry) => entry.key).sort(); expect(keys).toEqual(["doc1", "doc2"]); }); test("pending to ready transition populates replacedEntry", async () => { const t = initConvexTest(); const namespaceId = await setupTestNamespace(t); const entry = testEntryArgs(namespaceId); // First add - create as ready const firstResult = await t.mutation(api.entries.add, { entry, allChunks: [], }); expect(firstResult.created).toBe(true); expect(firstResult.status).toBe("ready"); // Second add - create as pending (no allChunks) const modifiedEntry = { ...entry, contentHash: "hash456", }; const pendingResult = await t.mutation(api.entries.add, { entry: modifiedEntry, // No allChunks - creates pending entry }); expect(pendingResult.created).toBe(true); expect(pendingResult.status).toBe("pending"); const chunksResult = await t.mutation(api.chunks.replaceChunksPage, { entryId: pendingResult.entryId, startOrder: 0, }); expect(chunksResult.status).toBe("ready"); // Promote to ready - this should replace the first entry const promoteResult = await t.mutation(api.entries.promoteToReady, { entryId: pendingResult.entryId, }); expect(promoteResult.replacedEntry).not.toBeNull(); expect(promoteResult.replacedEntry!.entryId).toBe(firstResult.entryId); // Verify the first entry is now replaced const firstDoc = await t.run(async (ctx) => { return ctx.db.get("entries", firstResult.entryId); }); expect(firstDoc!.status.kind).toBe("replaced"); }); test("deleteAsync deletes entry and all chunks", async () => { const t = initConvexTest(); const namespaceId = await setupTestNamespace(t); const entry = testEntryArgs(namespaceId); // Create entry with chunks const testChunks = [ { content: { text: "chunk 1 content", metadata: { type: "text" } }, embedding: Array.from({ length: 128 }, () => Math.random()), searchableText: "chunk 1 content", }, { content: { text: "chunk 2 content", metadata: { type: "text" } }, embedding: Array.from({ length: 128 }, () => Math.random()), searchableText: "chunk 2 content", }, ]; const result = await t.mutation(api.entries.add, { entry, allChunks: testChunks, }); expect(result.created).toBe(true); expect(result.status).toBe("ready"); // Verify entry and chunks exist before deletion const entryBefore = await t.run(async (ctx) => { return ctx.db.get("entries", result.entryId); }); expect(entryBefore).toBeDefined(); const chunksBefore = await t.run(async (ctx) => { return ctx.db .query("chunks") .filter((q) => q.eq(q.field("entryId"), result.entryId)) .collect(); }); expect(chunksBefore).toHaveLength(2); // Delete the entry await t.mutation(api.entries.deleteAsync, { entryId: result.entryId, startOrder: 0, }); // Wait for async deletion to complete by repeatedly checking await t.finishInProgressScheduledFunctions(); // Verify entry is deleted const entryAfter = await t.run(async (ctx) => { return ctx.db.get("entries", result.entryId); }); expect(entryAfter).toBeNull(); // Verify chunks are deleted const chunksAfter = await t.run(async (ctx) => { return ctx.db .query("chunks") .filter((q) => q.eq(q.field("entryId"), result.entryId)) .collect(); }); expect(chunksAfter).toHaveLength(0); }); test("deleteSync deletes entry and all chunks synchronously", async () => { const t = initConvexTest(); const namespaceId = await setupTestNamespace(t); const entry = testEntryArgs(namespaceId); // Create entry with chunks const testChunks = [ { content: { text: "sync chunk 1", metadata: { type: "text" } }, embedding: Array.from({ length: 128 }, () => Math.random()), searchableText: "sync chunk 1", }, { content: { text: "sync chunk 2", metadata: { type: "text" } }, embedding: Array.from({ length: 128 }, () => Math.random()), searchableText: "sync chunk 2", }, ]; const result = await t.mutation(api.entries.add, { entry, allChunks: testChunks, }); expect(result.created).toBe(true); expect(result.status).toBe("ready"); // Verify entry and chunks exist before deletion const entryBefore = await t.run(async (ctx) => { return ctx.db.get("entries", result.entryId); }); expect(entryBefore).toBeDefined(); const chunksBefore = await t.run(async (ctx) => { return ctx.db .query("chunks") .filter((q) => q.eq(q.field("entryId"), result.entryId)) .collect(); }); expect(chunksBefore).toHaveLength(2); // Delete the entry synchronously await t.action(api.entries.deleteSync, { entryId: result.entryId, }); // Verify entry is deleted const entryAfter = await t.run(async (ctx) => { return ctx.db.get("entries", result.entryId); }); expect(entryAfter).toBeNull(); // Verify chunks are deleted const chunksAfter = await t.run(async (ctx) => { return ctx.db .query("chunks") .filter((q) => q.eq(q.field("entryId"), result.entryId)) .collect(); }); expect(chunksAfter).toHaveLength(0); }); test("deleteByKeyAsync deletes all entries with the given key", async () => { const t = initConvexTest(); const namespaceId = await setupTestNamespace(t); const entry1 = testEntryArgs(namespaceId, "shared-key"); const entry2 = { ...testEntryArgs(namespaceId, "shared-key"), contentHash: "hash456", }; const entry3 = testEntryArgs(namespaceId, "different-key"); // Create multiple entries with same key and one with different key const result1 = await t.mutation(api.entries.add, { entry: entry1, allChunks: [ { content: { text: "content 1" }, embedding: Array.from({ length: 128 }, () => Math.random()), }, ], }); expect(result1.status).toBe("ready"); const result2 = await t.mutation(api.entries.add, { entry: entry2, allChunks: [ { content: { text: "content 2" }, embedding: Array.from({ length: 128 }, () => Math.random()), }, ], }); expect(result2.status).toBe("pending"); const result3 = await t.mutation(api.entries.add, { entry: entry3, allChunks: [ { content: { text: "content 3" }, embedding: Array.from({ length: 128 }, () => Math.random()), }, ], }); expect(result3.status).toBe("ready"); // Verify all entries exist const entriesBefore = await t.run(async (ctx) => { return ctx.db .query("entries") .filter((q) => q.eq(q.field("namespaceId"), namespaceId)) .collect(); }); expect(entriesBefore).toHaveLength(3); const sharedBefore = await t.query( internal.entries.getEntriesForNamespaceByKey, { namespaceId, key: "shared-key", }, ); expect(sharedBefore).toHaveLength(2); // Delete entries by key await t.mutation(api.entries.deleteByKeyAsync, { namespaceId, key: "shared-key", }); // Wait for async deletion to complete await t.finishAllScheduledFunctions(vi.runAllTimers); // Verify only entries with "shared-key" are deleted const entriesAfter = await t.run(async (ctx) => { return ctx.db .query("entries") .filter((q) => q.eq(q.field("namespaceId"), namespaceId)) .collect(); }); expect(entriesAfter).toHaveLength(1); expect(entriesAfter[0].key).toBe("different-key"); expect(entriesAfter[0]._id).toBe(result3.entryId); const sharedAfter = await t.query( internal.entries.getEntriesForNamespaceByKey, { namespaceId, key: "shared-key" }, ); expect(sharedAfter).toHaveLength(0); // Verify chunks from deleted entries are also deleted const chunksAfter = await t.run(async (ctx) => { return ctx.db.query("chunks").collect(); }); expect(chunksAfter).toHaveLength(1); // Only chunk from entry3 should remain }); test("deleteByKeySync deletes all entries with the given key synchronously", async () => { const t = initConvexTest(); const namespaceId = await setupTestNamespace(t); const entry1 = testEntryArgs(namespaceId, "sync-key"); const entry2 = { ...testEntryArgs(namespaceId, "sync-key"), contentHash: "hash789", }; const entry3 = testEntryArgs(namespaceId, "keep-key"); // Create multiple entries with same key and one with different key const result1 = await t.mutation(api.entries.add, { entry: entry1, allChunks: [ { content: { text: "sync content 1" }, embedding: Array.from({ length: 128 }, () => Math.random()), }, ], }); expect(result1.status).toBe("ready"); const result2 = await t.mutation(api.entries.add, { entry: entry2, allChunks: [ { content: { text: "sync content 2" }, embedding: Array.from({ length: 128 }, () => Math.random()), }, ], }); expect(result2.status).toBe("pending"); const result3 = await t.mutation(api.entries.add, { entry: entry3, allChunks: [ { content: { text: "sync content 3" }, embedding: Array.from({ length: 128 }, () => Math.random()), }, ], }); expect(result3.status).toBe("ready"); // Verify all entries exist const entriesBefore = await t.run(async (ctx) => { return ctx.db .query("entries") .filter((q) => q.eq(q.field("namespaceId"), namespaceId)) .collect(); }); expect(entriesBefore).toHaveLength(3); // Delete entries by key synchronously await t.action(api.entries.deleteByKeySync, { namespaceId, key: "sync-key", }); // Verify only entries with "sync-key" are deleted const entriesAfter = await t.run(async (ctx) => { return ctx.db .query("entries") .filter((q) => q.eq(q.field("namespaceId"), namespaceId)) .collect(); }); expect(entriesAfter).toHaveLength(1); expect(entriesAfter[0].key).toBe("keep-key"); expect(entriesAfter[0]._id).toBe(result3.entryId); // Verify chunks from deleted entries are also deleted const chunksAfter = await t.run(async (ctx) => { return ctx.db.query("chunks").collect(); }); expect(chunksAfter).toHaveLength(1); // Only chunk from entry3 should remain }); test("deleteByKeyAsync handles entries without key gracefully", async () => { const t = initConvexTest(); const namespaceId = await setupTestNamespace(t); const entryWithKey = testEntryArgs(namespaceId, "has-key"); const entryWithoutKey = { ...testEntryArgs(namespaceId), key: undefined }; // Create entries const result1 = await t.mutation(api.entries.add, { entry: entryWithKey, allChunks: [], }); expect(result1.status).toBe("ready"); const result2 = await t.mutation(api.entries.add, { entry: entryWithoutKey, allChunks: [], }); // Delete by key - should only affect entries with that key await t.mutation(api.entries.deleteByKeyAsync, { namespaceId, key: "has-key", }); await t.finishAllScheduledFunctions(vi.runAllTimers); // Verify only the entry with the specified key is deleted const entriesAfter = await t.run(async (ctx) => { return ctx.db .query("entries") .filter((q) => q.eq(q.field("namespaceId"), namespaceId)) .collect(); }); expect(entriesAfter).toHaveLength(1); expect(entriesAfter[0]._id).toBe(result2.entryId); expect(entriesAfter[0].key).toBeUndefined(); }); test("deleteByKeyAsync with beforeVersion parameter", async () => { const t = initConvexTest(); const namespaceId = await setupTestNamespace(t); const entry = testEntryArgs(namespaceId, "versioned-key"); // Create multiple versions of the same entry const result1 = await t.mutation(api.entries.add, { entry, allChunks: [], }); expect(result1.status).toBe("ready"); const result2 = await t.mutation(api.entries.add, { entry: { ...entry, contentHash: "hash456" }, allChunks: [], }); expect(result2.status).toBe("pending"); const result3 = await t.mutation(api.entries.add, { entry: { ...entry, contentHash: "hash789" }, allChunks: [], }); // Get the versions to understand ordering const allEntries = await t.run(async (ctx) => { return ctx.db .query("entries") .filter((q) => q.and( q.eq(q.field("namespaceId"), namespaceId), q.eq(q.field("key"), "versioned-key"), ), ) .collect(); }); const sortedEntries = allEntries.sort((a, b) => a.version - b.version); expect(sortedEntries).toHaveLength(3); // Delete entries before version 2 (should delete version 0 and 1) await t.mutation(api.entries.deleteByKeyAsync, { namespaceId, key: "versioned-key", beforeVersion: 2, }); await t.finishAllScheduledFunctions(vi.runAllTimers); // Should only have the latest version (version 2) remaining const remainingEntries = await t.run(async (ctx) => { return ctx.db .query("entries") .filter((q) => q.and( q.eq(q.field("namespaceId"), namespaceId), q.eq(q.field("key"), "versioned-key"), ), ) .collect(); }); expect(remainingEntries).toHaveLength(1); expect(remainingEntries[0].version).toBe(2); expect(remainingEntries[0]._id).toBe(result3.entryId); }); });