import { createMetadataStore, createVerifiedTestIdentity } from "@datafoundry/metadata";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import type { EmbeddingService } from "./embedding-service.js";
import { LocalKnowledgeService } from "./index.js";
const roots: string[] = [];
const embeddingConfig = {
api_key: "test-key",
base_url: "https://example.test/v1",
model: "text-embedding-test",
provider: "openai-compatible"
};
const createHarness = (embeddingService?: EmbeddingService) => {
const root = mkdtempSync(join(tmpdir(), "knowledge-lifecycle-"));
roots.push(root);
const metadataStore = createMetadataStore({
database_path: join(root, "metadata.sqlite")
});
const { userId, workspaceId } = createVerifiedTestIdentity(metadataStore);
metadataStore.configResources.upsert({
id: "kb-1",
workspace_id: workspaceId,
user_id: userId,
kind: "knowledge-base",
name: "KB",
payload: {
embeddingBaseUrl: embeddingConfig.base_url,
embeddingModel: embeddingConfig.model,
embeddingProvider: embeddingConfig.provider
},
default_enabled: true,
status: "ready"
});
const knowledge = new LocalKnowledgeService(metadataStore, {
...(embeddingService
? { embedding: embeddingConfig, embeddingService }
: {})
});
return { knowledge, metadataStore, root, userId };
};
afterEach(() => {
for (const root of roots.splice(0)) {
rmSync(root, { force: true, recursive: true });
}
});
describe("LocalKnowledgeService document lifecycle", () => {
it("hard-deletes a document and cascades chunks, embeddings, and FTS rows", async () => {
const embed: EmbeddingService = {
embed: async (texts) => texts.map((_, index) => [index + 1, 0, 0])
};
const { knowledge, metadataStore, userId } = createHarness(embed);
const doc = await knowledge.ingestText({
user_id: userId,
collection_id: "kb-1",
filename: "keep-me.md",
content: "alpha revenue metric one"
});
const other = await knowledge.ingestText({
user_id: userId,
collection_id: "kb-1",
filename: "other.md",
content: "beta cost metric two"
});
const deleted = knowledge.deleteDocument({
user_id: userId,
collection_id: "kb-1",
document_id: doc.id
});
expect(deleted).toEqual({ deleted: true, id: doc.id });
const remaining = knowledge.listDocuments({ user_id: userId, collection_id: "kb-1" });
expect(remaining.map((item) => item.id)).toEqual([other.id]);
const chunkCount = metadataStore.db.prepare(
"SELECT COUNT(*) AS count FROM knowledge_chunks WHERE document_id = ?"
).get(doc.id) as { count: number };
expect(chunkCount.count).toBe(0);
const embeddingCount = metadataStore.db.prepare(
"SELECT COUNT(*) AS count FROM knowledge_embeddings WHERE document_id = ?"
).get(doc.id) as { count: number };
expect(embeddingCount.count).toBe(0);
const hits = await knowledge.retrieve({
user_id: userId,
collection_id: "kb-1",
query: "alpha revenue metric"
});
expect(hits.every((hit) => hit.document_id !== doc.id)).toBe(true);
});
it("reindexes a failed document and marks it ready on success", async () => {
let shouldFail = true;
const embed: EmbeddingService = {
embed: async (texts) => {
if (shouldFail) {
throw new Error("EMBEDDING_REQUEST_FAILED:500:boom");
}
return texts.map((_, index) => [index + 1, 0, 0]);
}
};
const { knowledge, metadataStore, userId } = createHarness(embed);
await expect(knowledge.ingestText({
user_id: userId,
collection_id: "kb-1",
filename: "failed.md",
content: "alpha revenue metric recoverable"
})).rejects.toThrow(/EMBEDDING_REQUEST_FAILED/);
const failed = knowledge.listDocuments({ user_id: userId, collection_id: "kb-1" })[0];
expect(failed?.status).toBe("failed");
shouldFail = false;
const recovered = await knowledge.reindexDocument({
user_id: userId,
collection_id: "kb-1",
document_id: failed!.id
});
expect(recovered.status).toBe("ready");
const embeddingCount = metadataStore.db.prepare(
"SELECT COUNT(*) AS count FROM knowledge_embeddings WHERE document_id = ?"
).get(failed!.id) as { count: number };
expect(embeddingCount.count).toBeGreaterThan(0);
});
it("marks documents ready after a successful collection reindex", async () => {
let shouldFail = true;
const embed: EmbeddingService = {
embed: async (texts) => {
if (shouldFail) {
throw new Error("EMBEDDING_REQUEST_FAILED:500:boom");
}
return texts.map((_, index) => [index + 1, 0, 0]);
}
};
const { knowledge, userId } = createHarness(embed);
await expect(knowledge.ingestText({
user_id: userId,
collection_id: "kb-1",
filename: "stuck.md",
content: "alpha revenue metric stuck"
})).rejects.toThrow(/EMBEDDING_REQUEST_FAILED/);
expect(knowledge.listDocuments({ user_id: userId, collection_id: "kb-1" })[0]?.status).toBe("failed");
shouldFail = false;
await knowledge.reindex({ user_id: userId, collection_id: "kb-1" });
expect(knowledge.listDocuments({ user_id: userId, collection_id: "kb-1" })[0]?.status).toBe("ready");
});
it("clears partial vectors and marks documents failed when collection reindex aborts mid-batch", async () => {
let failAfterBatches = Number.POSITIVE_INFINITY;
let batches = 0;
const embed: EmbeddingService = {
embed: async (texts) => {
batches += 1;
if (batches > failAfterBatches) {
throw new Error("EMBEDDING_REQUEST_FAILED:500:mid-reindex");
}
return texts.map((_, index) => [index + 1, 0, 0]);
}
};
const { knowledge, metadataStore, userId } = createHarness(embed);
for (let index = 0; index < 33; index += 1) {
const doc = await knowledge.ingestText({
user_id: userId,
collection_id: "kb-1",
filename: `doc-${index}.md`,
content: `alpha revenue metric document ${index} unique content here`
});
expect(doc.status).toBe("ready");
}
batches = 0;
failAfterBatches = 1;
await expect(knowledge.reindex({
user_id: userId,
collection_id: "kb-1"
})).rejects.toThrow(/EMBEDDING_REQUEST_FAILED:500:mid-reindex/);
const documents = knowledge.listDocuments({ user_id: userId, collection_id: "kb-1" });
expect(documents).toHaveLength(33);
expect(documents.every((item) => item.status === "failed")).toBe(true);
const embeddingCount = metadataStore.db.prepare(
"SELECT COUNT(*) AS count FROM knowledge_embeddings WHERE user_id = ? AND collection_id = ?"
).get(userId, "kb-1") as { count: number };
expect(embeddingCount.count).toBe(0);
});
it("marks failed when single-document reindex fails", async () => {
const embed: EmbeddingService = {
embed: async () => {
throw new Error("EMBEDDING_REQUEST_FAILED:500:still-broken");
}
};
const { knowledge, metadataStore, userId } = createHarness();
const doc = await knowledge.ingestText({
user_id: userId,
collection_id: "kb-1",
filename: "fts-only.md",
content: "alpha revenue metric fts"
});
expect(doc.status).toBe("ready");
const knowledgeWithEmbed = new LocalKnowledgeService(metadataStore, {
embedding: embeddingConfig,
embeddingService: embed
});
await expect(knowledgeWithEmbed.reindexDocument({
user_id: userId,
collection_id: "kb-1",
document_id: doc.id
})).rejects.toThrow(/EMBEDDING_REQUEST_FAILED/);
expect(knowledgeWithEmbed.listDocuments({ user_id: userId, collection_id: "kb-1" })[0]?.status)
.toBe("failed");
});
});