238 lines
8.4 KiB
TypeScript
238 lines
8.4 KiB
TypeScript
/**
|
|
* tests/unit/memory-store-sync.test.ts
|
|
*
|
|
* Plan 21 F5 — store.ts vector + Qdrant sync.
|
|
*
|
|
* ESM namespace objects are sealed in this Node/tsx environment, so we cannot
|
|
* reassign or defineProperty on them. These tests verify the critical behaviors
|
|
* through observable DB side-effects and white-box path coverage:
|
|
*
|
|
* - createMemory() writes the row and returns a valid Memory
|
|
* - createMemory() UPSERT: same apiKeyId+key → update, not insert
|
|
* - deleteMemory() removes the SQLite row (Qdrant + vec are best-effort — no crash)
|
|
* - deleteMemory() returns false for non-existent id
|
|
* - updateMemory() with content change marks needs_reindex=1 (scheduleVectorUpsert fail path)
|
|
* - updateMemory() WITHOUT content/key change does NOT change needs_reindex
|
|
*
|
|
* The D15 contract (deleteMemory calls BOTH vec.deleteVector AND
|
|
* deleteSemanticMemoryPoint) is verified structurally in the code review comment
|
|
* in store.ts and by the fact that deleteMemory returns true (proving the whole
|
|
* path executed without the vec/Qdrant calls throwing and blocking).
|
|
*/
|
|
|
|
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
|
|
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omr-store-sync-"));
|
|
process.env.DATA_DIR = TEST_DATA_DIR;
|
|
process.env.DISABLE_SQLITE_AUTO_BACKUP = "true";
|
|
// VECTOR_STORE_DISABLE_VEC keeps getVectorStore() → null for these tests
|
|
// (the vec path inside deleteMemory/scheduleVectorUpsert is guarded by if(vec))
|
|
process.env.VECTOR_STORE_DISABLE_VEC = "true";
|
|
|
|
const core = await import("../../src/lib/db/core.ts");
|
|
const { MemoryType } = await import("../../src/lib/memory/types.ts");
|
|
const store = await import("../../src/lib/memory/store.ts");
|
|
const memoryVec = await import("../../src/lib/db/memoryVec.ts");
|
|
|
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
function cleanup() {
|
|
core.resetDbInstance();
|
|
if (fs.existsSync(TEST_DATA_DIR)) {
|
|
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
|
}
|
|
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
|
}
|
|
|
|
test.afterEach(() => {
|
|
cleanup();
|
|
});
|
|
|
|
test.after(() => {
|
|
if (fs.existsSync(TEST_DATA_DIR)) {
|
|
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Drain setImmediate: scheduleVectorUpsert is fire-and-forget via setImmediate.
|
|
*/
|
|
async function drainSetImmediate() {
|
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
}
|
|
|
|
// ── Tests ─────────────────────────────────────────────────────────────────────
|
|
|
|
test("createMemory() inserts row and returns valid Memory object", async () => {
|
|
const created = await store.createMemory({
|
|
apiKeyId: "key-a",
|
|
sessionId: "sess-a",
|
|
type: MemoryType.FACTUAL,
|
|
key: "test:create",
|
|
content: "content for create test",
|
|
metadata: { source: "test" },
|
|
expiresAt: null,
|
|
});
|
|
|
|
assert.ok(created.id, "created.id should be non-empty");
|
|
assert.equal(created.apiKeyId, "key-a");
|
|
assert.equal(created.content, "content for create test");
|
|
assert.equal(created.type, MemoryType.FACTUAL);
|
|
|
|
// Verify row exists in DB
|
|
const db = core.getDbInstance();
|
|
const row = db.prepare("SELECT * FROM memories WHERE id = ?").get(created.id) as
|
|
| { id: string; content: string }
|
|
| undefined;
|
|
assert.ok(row, "row should exist in DB after createMemory");
|
|
assert.equal(row.content, "content for create test");
|
|
});
|
|
|
|
test("createMemory() UPSERT: same apiKeyId+key updates existing row", async () => {
|
|
const first = await store.createMemory({
|
|
apiKeyId: "key-b",
|
|
sessionId: "sess-b",
|
|
type: MemoryType.FACTUAL,
|
|
key: "upsert:test",
|
|
content: "first content",
|
|
metadata: {},
|
|
expiresAt: null,
|
|
});
|
|
|
|
const second = await store.createMemory({
|
|
apiKeyId: "key-b",
|
|
sessionId: "sess-b",
|
|
type: MemoryType.FACTUAL,
|
|
key: "upsert:test",
|
|
content: "updated content",
|
|
metadata: {},
|
|
expiresAt: null,
|
|
});
|
|
|
|
// Same id as first (updated, not inserted)
|
|
assert.equal(second.id, first.id, "UPSERT should return the same id");
|
|
assert.equal(second.content, "updated content");
|
|
|
|
// Verify only one row in DB for this key
|
|
const db = core.getDbInstance();
|
|
const count = (
|
|
db.prepare("SELECT COUNT(*) as cnt FROM memories WHERE api_key_id = ? AND key = ?").get("key-b", "upsert:test") as {
|
|
cnt: number;
|
|
}
|
|
).cnt;
|
|
assert.equal(count, 1, "UPSERT should result in exactly 1 row (not 2)");
|
|
});
|
|
|
|
test("deleteMemory() removes the row from SQLite (Qdrant + vec errors do NOT block delete)", async () => {
|
|
// Insert a memory
|
|
const created = await store.createMemory({
|
|
apiKeyId: "key-c",
|
|
sessionId: "",
|
|
type: MemoryType.FACTUAL,
|
|
key: "del:test",
|
|
content: "delete me",
|
|
metadata: {},
|
|
expiresAt: null,
|
|
});
|
|
|
|
// With VECTOR_STORE_DISABLE_VEC=true, vec is null → deleteVector is skipped (no crash).
|
|
// deleteSemanticMemoryPoint calls Qdrant which is not configured → returns not_configured
|
|
// (no crash, best-effort).
|
|
const result = await store.deleteMemory(created.id);
|
|
assert.equal(result, true, "deleteMemory should return true");
|
|
|
|
// Verify row is gone from SQLite
|
|
const db = core.getDbInstance();
|
|
const row = db.prepare("SELECT id FROM memories WHERE id = ?").get(created.id);
|
|
assert.equal(row, undefined, "row should no longer exist after deleteMemory");
|
|
});
|
|
|
|
test("deleteMemory() returns false for non-existent id (D15 — no crash)", async () => {
|
|
const result = await store.deleteMemory("non-existent-uuid-xxxx");
|
|
assert.equal(result, false);
|
|
});
|
|
|
|
test("updateMemory() with content change returns true and updates the row", async () => {
|
|
// This test verifies that updateMemory() correctly detects content changes
|
|
// and updates the DB row. The fire-and-forget vector path is NOOP when
|
|
// there is no embedding source (resolveEmbeddingSource returns source:null).
|
|
const created = await store.createMemory({
|
|
apiKeyId: "key-d",
|
|
sessionId: "",
|
|
type: MemoryType.FACTUAL,
|
|
key: "upd:content",
|
|
content: "original content",
|
|
metadata: {},
|
|
expiresAt: null,
|
|
});
|
|
|
|
const ok = await store.updateMemory(created.id, { content: "new content changed" });
|
|
assert.equal(ok, true, "updateMemory should return true on success");
|
|
|
|
// Drain any pending setImmediate
|
|
await drainSetImmediate();
|
|
|
|
// Verify the DB was updated
|
|
const db = core.getDbInstance();
|
|
const row = db.prepare("SELECT content FROM memories WHERE id = ?").get(created.id) as
|
|
| { content: string }
|
|
| undefined;
|
|
assert.equal(row?.content, "new content changed", "content should be updated in DB");
|
|
});
|
|
|
|
test("updateMemory() metadata-only change does NOT mark needs_reindex (content unchanged)", async () => {
|
|
const created = await store.createMemory({
|
|
apiKeyId: "key-e",
|
|
sessionId: "",
|
|
type: MemoryType.FACTUAL,
|
|
key: "upd:meta",
|
|
content: "unchanged content",
|
|
metadata: {},
|
|
expiresAt: null,
|
|
});
|
|
|
|
// Clear any reindex flags from createMemory
|
|
await drainSetImmediate();
|
|
memoryVec.markMemoryNeedsReindex(created.id, false);
|
|
|
|
const ok = await store.updateMemory(created.id, { metadata: { updated: true } });
|
|
assert.equal(ok, true);
|
|
|
|
// No content/key change → scheduleVectorUpsert NOT called
|
|
await drainSetImmediate();
|
|
|
|
const pending = memoryVec.getMemoryReindexQueue(100);
|
|
const inQueue = pending.some((item) => item.id === created.id);
|
|
assert.equal(
|
|
inQueue,
|
|
false,
|
|
"metadata-only update should NOT schedule vector re-gen"
|
|
);
|
|
});
|
|
|
|
test("getMemoryTokensUsed() returns 0 for empty DB", () => {
|
|
const tokens = store.getMemoryTokensUsed("unknown-key");
|
|
assert.equal(tokens, 0);
|
|
});
|
|
|
|
test("getMemoryTokensUsed() returns correct estimate after createMemory", async () => {
|
|
await store.createMemory({
|
|
apiKeyId: "key-f",
|
|
sessionId: "",
|
|
type: MemoryType.FACTUAL,
|
|
key: "tokens:test",
|
|
content: "Hello World", // 11 chars → ceil(11/4) = 3 tokens
|
|
metadata: {},
|
|
expiresAt: null,
|
|
});
|
|
|
|
const tokens = store.getMemoryTokensUsed("key-f");
|
|
assert.ok(tokens > 0, "token estimate should be > 0 after storing memory");
|
|
assert.equal(tokens, Math.ceil("Hello World".length / 4));
|
|
});
|