Add an optional PostgreSQL backend (QMD_BACKEND=pg) alongside the unchanged default SQLite path. PG store uses pgvector (HNSW) for vectors and pg_jieba + pg_trgm for full-text/Chinese tokenization, with a namespace column isolating multi-agent memory (openclaw/hermes). - src/pg/: config, db-pg, schema bootstrap, memory store - MCP memory_add/memory_search/memory_get tools; qmd pg status + memory CLI - connection via QMD_PG_URL/DATABASE_URL/qmd config, stunnel TLS 5443 - tests: pg-config (unit) + pg-memory integration (gated on QMD_PG_URL) + pg-compose - docs/plan: plan, usage, test report, changelog; track docs/**/*.md SQLite path: zero regression (typecheck clean, 249 passed / 6 skipped). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
114 lines
4.1 KiB
TypeScript
114 lines
4.1 KiB
TypeScript
/**
|
|
* pg-memory.integration.test.ts - End-to-end tests against a live PostgreSQL.
|
|
*
|
|
* Skipped unless QMD_PG_URL is set. Spin up a PG with pgvector (+ optionally
|
|
* pg_jieba) and run:
|
|
*
|
|
* QMD_PG_URL='postgres://postgres:postgres@localhost:5432/postgres' \
|
|
* npx vitest run test/pg-memory.integration.test.ts
|
|
*
|
|
* Uses a deterministic stub embedder so the test needs no network or models —
|
|
* it verifies schema bootstrap, namespaced writes, hybrid (FTS+vector) search,
|
|
* RRF fusion, get/list/delete, and namespace isolation.
|
|
*/
|
|
|
|
import { describe, test, expect, beforeAll, afterAll } from "vitest";
|
|
import { PgMemoryStore, type Embedder } from "../src/pg/memory-store.js";
|
|
import { resolvePgConfig } from "../src/pg/config.js";
|
|
|
|
const PG_URL = process.env.QMD_PG_URL ?? process.env.DATABASE_URL;
|
|
|
|
/** Deterministic 16-dim embedder — char-frequency histogram, L2-normalized. */
|
|
const stubEmbedder: Embedder = {
|
|
async embedBatch(texts) {
|
|
return texts.map((t) => {
|
|
const v = new Array(16).fill(0);
|
|
for (const ch of t.toLowerCase()) v[ch.charCodeAt(0) % 16] += 1;
|
|
const norm = Math.sqrt(v.reduce((s, x) => s + x * x, 0)) || 1;
|
|
return { embedding: v.map((x) => x / norm), model: "stub-16" };
|
|
});
|
|
},
|
|
};
|
|
|
|
const NS = `test_${Date.now()}`;
|
|
|
|
describe.skipIf(!PG_URL)("PgMemoryStore (integration)", () => {
|
|
let store: PgMemoryStore;
|
|
|
|
beforeAll(async () => {
|
|
const config = { ...resolvePgConfig({ QMD_PG_URL: PG_URL!, QMD_NAMESPACE: NS }) };
|
|
store = await PgMemoryStore.open(config, stubEmbedder);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
if (store) {
|
|
// Best-effort cleanup of the test namespace.
|
|
try {
|
|
for (const m of await store.listMemories({ limit: 1000 })) {
|
|
await store.deleteMemory(m.key);
|
|
}
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
await store.close();
|
|
}
|
|
});
|
|
|
|
test("bootstraps schema with pgvector", async () => {
|
|
expect(store.capabilities.vector).toBe(true);
|
|
});
|
|
|
|
test("adds and retrieves a memory", async () => {
|
|
const res = await store.addMemory({
|
|
key: "note/auth",
|
|
title: "Auth design",
|
|
body: "The login flow uses OAuth tokens and a refresh rotation strategy.",
|
|
});
|
|
expect(res.namespace).toBe(NS);
|
|
expect(res.chunks).toBeGreaterThan(0);
|
|
expect(res.embedded).toBe(true);
|
|
|
|
const got = await store.getMemory("note/auth");
|
|
expect(got?.body).toContain("OAuth");
|
|
expect(got?.title).toBe("Auth design");
|
|
});
|
|
|
|
test("hybrid search finds a memory by keyword", async () => {
|
|
await store.addMemory({ key: "note/cache", body: "Redis caching layer with TTL eviction." });
|
|
const results = await store.searchMemory("redis caching", { limit: 5 });
|
|
expect(results.length).toBeGreaterThan(0);
|
|
expect(results.some((r) => r.key === "note/cache")).toBe(true);
|
|
// At least one signal contributed.
|
|
expect(results[0]!.lexRank !== null || results[0]!.vecRank !== null).toBe(true);
|
|
});
|
|
|
|
test("re-adding the same key updates it", async () => {
|
|
await store.addMemory({ key: "note/dup", body: "first version" });
|
|
await store.addMemory({ key: "note/dup", body: "second version updated" });
|
|
const got = await store.getMemory("note/dup");
|
|
expect(got?.body).toBe("second version updated");
|
|
const all = await store.listMemories({ limit: 1000 });
|
|
expect(all.filter((m) => m.key === "note/dup").length).toBe(1);
|
|
});
|
|
|
|
test("delete soft-removes a memory", async () => {
|
|
await store.addMemory({ key: "note/temp", body: "temporary" });
|
|
expect(await store.deleteMemory("note/temp")).toBe(true);
|
|
expect(await store.getMemory("note/temp")).toBeNull();
|
|
});
|
|
|
|
test("namespaces isolate memories", async () => {
|
|
const other = `${NS}_other`;
|
|
await store.addMemory({ key: "shared/key", body: "in default test ns" });
|
|
await store.addMemory({ key: "shared/key", body: "in other ns", namespace: other });
|
|
|
|
const a = await store.getMemory("shared/key");
|
|
const b = await store.getMemory("shared/key", { namespace: other });
|
|
expect(a?.body).toBe("in default test ns");
|
|
expect(b?.body).toBe("in other ns");
|
|
|
|
// cleanup other ns
|
|
await store.deleteMemory("shared/key", { namespace: other });
|
|
});
|
|
});
|