fix(ts-oss): return snake_case entity ids from the redis and valkey stores (#6902)
This commit is contained in:
@@ -105,6 +105,18 @@ const ENTITY_PARAMS = [
|
||||
// actor_id has no camelCase alias.
|
||||
const IDENTITY_KEYS = [...ENTITY_PARAMS, "actor_id"];
|
||||
|
||||
const PAYLOAD_METADATA_EXCLUDED_KEYS = new Set([
|
||||
"user_id",
|
||||
"agent_id",
|
||||
"run_id",
|
||||
"hash",
|
||||
"data",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
"textLemmatized",
|
||||
"attributedTo",
|
||||
]);
|
||||
|
||||
// Caller metadata must not overwrite or inject an identity scope (#6342 / #6367 / #6371).
|
||||
function stripIdentityKeys(
|
||||
metadata: Record<string, any> = {},
|
||||
@@ -1298,20 +1310,8 @@ export class Memory {
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
// Add additional metadata
|
||||
const excludedKeys = new Set([
|
||||
"userId",
|
||||
"agentId",
|
||||
"runId",
|
||||
"hash",
|
||||
"data",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
"textLemmatized",
|
||||
"attributedTo",
|
||||
]);
|
||||
for (const [key, value] of Object.entries(memory.payload)) {
|
||||
if (!excludedKeys.has(key)) {
|
||||
if (!PAYLOAD_METADATA_EXCLUDED_KEYS.has(key)) {
|
||||
memoryItem.metadata![key] = value;
|
||||
}
|
||||
}
|
||||
@@ -1567,18 +1567,6 @@ export class Memory {
|
||||
);
|
||||
|
||||
// Step 9: Format results
|
||||
const excludedKeys = new Set([
|
||||
"user_id",
|
||||
"agent_id",
|
||||
"run_id",
|
||||
"hash",
|
||||
"data",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
"textLemmatized",
|
||||
"attributedTo",
|
||||
]);
|
||||
|
||||
const results = scoredResults
|
||||
.filter((scored) => scored.payload?.data)
|
||||
.map((scored) => {
|
||||
@@ -1591,7 +1579,7 @@ export class Memory {
|
||||
updatedAt: payload.updatedAt,
|
||||
score: scored.score,
|
||||
metadata: Object.entries(payload)
|
||||
.filter(([key]) => !excludedKeys.has(key))
|
||||
.filter(([key]) => !PAYLOAD_METADATA_EXCLUDED_KEYS.has(key))
|
||||
.reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}),
|
||||
...(payload.user_id && { user_id: payload.user_id }),
|
||||
...(payload.agent_id && { agent_id: payload.agent_id }),
|
||||
@@ -1897,17 +1885,6 @@ export class Memory {
|
||||
? memories
|
||||
: memories.filter((mem) => !payloadIsExpired(mem.payload));
|
||||
|
||||
const excludedKeys = new Set([
|
||||
"user_id",
|
||||
"agent_id",
|
||||
"run_id",
|
||||
"hash",
|
||||
"data",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
"textLemmatized",
|
||||
"attributedTo",
|
||||
]);
|
||||
const results = visibleMemories.slice(0, topK).map((mem) => ({
|
||||
id: mem.id,
|
||||
memory: mem.payload.data,
|
||||
@@ -1915,7 +1892,7 @@ export class Memory {
|
||||
createdAt: mem.payload.createdAt,
|
||||
updatedAt: mem.payload.updatedAt,
|
||||
metadata: Object.entries(mem.payload)
|
||||
.filter(([key]) => !excludedKeys.has(key))
|
||||
.filter(([key]) => !PAYLOAD_METADATA_EXCLUDED_KEYS.has(key))
|
||||
.reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}),
|
||||
...(mem.payload.user_id && { user_id: mem.payload.user_id }),
|
||||
...(mem.payload.agent_id && { agent_id: mem.payload.agent_id }),
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
function toCamelCase(obj: Record<string, any>): Record<string, any> {
|
||||
if (typeof obj !== "object" || obj === null) return obj;
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(obj).map(([key, value]) => [
|
||||
key.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()),
|
||||
value,
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
export function toCamelCasePreservingIds(
|
||||
payload: Record<string, any>,
|
||||
): Record<string, any> {
|
||||
const { agent_id, run_id, user_id, ...rest } = payload;
|
||||
return {
|
||||
...toCamelCase(rest),
|
||||
...(agent_id !== undefined && { agent_id }),
|
||||
...(run_id !== undefined && { run_id }),
|
||||
...(user_id !== undefined && { user_id }),
|
||||
};
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
import { VectorStore } from "./base";
|
||||
import { SearchFilters, VectorStoreConfig, VectorStoreResult } from "../types";
|
||||
import { loadPeer } from "../utils/load_peer";
|
||||
import { toCamelCasePreservingIds } from "../utils/casing";
|
||||
|
||||
/**
|
||||
* Escape RediSearch TAG filter special characters. Any punctuation in the
|
||||
@@ -145,18 +146,6 @@ export function buildRedisFilterExpr(filters?: SearchFilters): string {
|
||||
return conditions.length > 0 ? conditions.join(" ") : "*";
|
||||
}
|
||||
|
||||
// Utility function to convert object keys to camelCase
|
||||
function toCamelCase(obj: Record<string, any>): Record<string, any> {
|
||||
if (typeof obj !== "object" || obj === null) return obj;
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(obj).map(([key, value]) => [
|
||||
key.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()),
|
||||
value,
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
export class RedisDB implements VectorStore {
|
||||
private client!: RedisClientType<
|
||||
RedisDefaultModules & RedisModules & RedisFunctions & RedisScripts
|
||||
@@ -468,7 +457,7 @@ export class RedisDB implements VectorStore {
|
||||
|
||||
return {
|
||||
id: doc.value.memory_id,
|
||||
payload: toCamelCase(resultPayload),
|
||||
payload: toCamelCasePreservingIds(resultPayload),
|
||||
score: Math.max(0, 1 - (Number(doc.value.__vector_score) ?? 0)),
|
||||
};
|
||||
});
|
||||
@@ -573,7 +562,7 @@ export class RedisDB implements VectorStore {
|
||||
|
||||
return {
|
||||
id: vectorId,
|
||||
payload: toCamelCase(payload),
|
||||
payload: toCamelCasePreservingIds(payload),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error getting vector:", error);
|
||||
@@ -680,7 +669,7 @@ export class RedisDB implements VectorStore {
|
||||
|
||||
const items = results.documents.map((doc) => ({
|
||||
id: doc.value.memory_id,
|
||||
payload: toCamelCase({
|
||||
payload: toCamelCasePreservingIds({
|
||||
hash: doc.value.hash,
|
||||
data: doc.value.memory,
|
||||
created_at: new Date(parseInt(doc.value.created_at)).toISOString(),
|
||||
|
||||
@@ -2,6 +2,7 @@ import { VectorStore } from "./base";
|
||||
import { SearchFilters, VectorStoreResult } from "../types";
|
||||
import { ValkeyConfig } from "../types/valkey";
|
||||
import { loadPeer } from "../utils/load_peer";
|
||||
import { toCamelCasePreservingIds } from "../utils/casing";
|
||||
|
||||
interface ValkeyClient {
|
||||
call: (...args: (string | number | Buffer)[]) => Promise<unknown>;
|
||||
@@ -45,16 +46,6 @@ function toSnakeCase(obj: Record<string, any>): Record<string, any> {
|
||||
);
|
||||
}
|
||||
|
||||
function toCamelCase(obj: Record<string, any>): Record<string, any> {
|
||||
if (typeof obj !== "object" || obj === null) return obj;
|
||||
return Object.fromEntries(
|
||||
Object.entries(obj).map(([key, value]) => [
|
||||
key.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()),
|
||||
value,
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
interface ValkeySearchDoc {
|
||||
memory_id?: string;
|
||||
hash?: string;
|
||||
@@ -388,7 +379,7 @@ export class ValkeyDB implements VectorStore {
|
||||
|
||||
return {
|
||||
id: doc.memory_id ?? "",
|
||||
payload: toCamelCase(resultPayload),
|
||||
payload: toCamelCasePreservingIds(resultPayload),
|
||||
score,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -269,9 +269,7 @@ describe("Memory - add()", () => {
|
||||
"runId",
|
||||
"actor_id",
|
||||
]) {
|
||||
if (key !== canonicalKey) {
|
||||
expect(stored!.metadata).not.toHaveProperty(key);
|
||||
}
|
||||
expect(stored!.metadata).not.toHaveProperty(key);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -307,10 +305,7 @@ describe("Memory - add()", () => {
|
||||
}
|
||||
}
|
||||
expect(stored!.metadata).toEqual(
|
||||
expect.objectContaining({
|
||||
[filterKey]: filterValue,
|
||||
ordinary: "preserved",
|
||||
}),
|
||||
expect.objectContaining({ ordinary: "preserved" }),
|
||||
);
|
||||
for (const key of [
|
||||
"user_id",
|
||||
@@ -321,7 +316,6 @@ describe("Memory - add()", () => {
|
||||
"runId",
|
||||
"actor_id",
|
||||
]) {
|
||||
if (key === filterKey) continue;
|
||||
expect(stored!.metadata).not.toHaveProperty(key);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -128,6 +128,16 @@ describe("Memory - get()", () => {
|
||||
expect(item!.createdAt).toBeDefined();
|
||||
expect(new Date(item!.createdAt!).toString()).not.toBe("Invalid Date");
|
||||
});
|
||||
|
||||
test("does not duplicate the entity id inside metadata", async () => {
|
||||
const addResult: SearchResult = await memory.add("Metadata leak test", {
|
||||
userId,
|
||||
});
|
||||
const item: any = await memory.get(addResult.results[0].id);
|
||||
expect(item.user_id).toBe(userId);
|
||||
expect(item.metadata).not.toHaveProperty("user_id");
|
||||
expect(item.metadata).not.toHaveProperty("userId");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── update() ────────────────────────────────────────────
|
||||
|
||||
@@ -125,4 +125,88 @@ describe("RedisDB – entity payload handling", () => {
|
||||
expect(entry.created_at).toBeGreaterThan(0);
|
||||
expect(Number.isNaN(entry.created_at)).toBe(false);
|
||||
});
|
||||
|
||||
test("search returns entity ids as snake_case", async () => {
|
||||
mockClient.ft.search.mockResolvedValue({
|
||||
total: 1,
|
||||
documents: [
|
||||
{
|
||||
id: "mem0:test:mem-1",
|
||||
value: {
|
||||
memory_id: "mem-1",
|
||||
hash: "h1",
|
||||
memory: "likes coffee",
|
||||
created_at: "1700000000000",
|
||||
agent_id: "agent-1",
|
||||
run_id: "run-1",
|
||||
user_id: "user-1",
|
||||
metadata: "{}",
|
||||
__vector_score: 0.1,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const results = await store.search([0.1, 0.2, 0.3, 0.4], 5);
|
||||
|
||||
expect(results[0].payload.user_id).toBe("user-1");
|
||||
expect(results[0].payload.agent_id).toBe("agent-1");
|
||||
expect(results[0].payload.run_id).toBe("run-1");
|
||||
expect(results[0].payload).not.toHaveProperty("userId");
|
||||
expect(results[0].payload).not.toHaveProperty("agentId");
|
||||
expect(results[0].payload).not.toHaveProperty("runId");
|
||||
});
|
||||
|
||||
test("get returns entity ids as snake_case", async () => {
|
||||
mockClient.exists.mockResolvedValue(1);
|
||||
mockClient.hGetAll.mockResolvedValue({
|
||||
memory_id: "mem-1",
|
||||
hash: "h1",
|
||||
memory: "likes coffee",
|
||||
created_at: "1700000000000",
|
||||
agent_id: "agent-1",
|
||||
run_id: "run-1",
|
||||
user_id: "user-1",
|
||||
metadata: "{}",
|
||||
});
|
||||
|
||||
const result = await store.get("mem-1");
|
||||
|
||||
expect(result?.payload.user_id).toBe("user-1");
|
||||
expect(result?.payload.agent_id).toBe("agent-1");
|
||||
expect(result?.payload.run_id).toBe("run-1");
|
||||
expect(result?.payload).not.toHaveProperty("userId");
|
||||
expect(result?.payload).not.toHaveProperty("agentId");
|
||||
expect(result?.payload).not.toHaveProperty("runId");
|
||||
});
|
||||
|
||||
test("list returns entity ids as snake_case", async () => {
|
||||
mockClient.ft.search.mockResolvedValue({
|
||||
total: 1,
|
||||
documents: [
|
||||
{
|
||||
id: "mem0:test:mem-1",
|
||||
value: {
|
||||
memory_id: "mem-1",
|
||||
hash: "h1",
|
||||
memory: "likes coffee",
|
||||
created_at: "1700000000000",
|
||||
agent_id: "agent-1",
|
||||
run_id: "run-1",
|
||||
user_id: "user-1",
|
||||
metadata: "{}",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const [items] = await store.list();
|
||||
|
||||
expect(items[0].payload.user_id).toBe("user-1");
|
||||
expect(items[0].payload.agent_id).toBe("agent-1");
|
||||
expect(items[0].payload.run_id).toBe("run-1");
|
||||
expect(items[0].payload).not.toHaveProperty("userId");
|
||||
expect(items[0].payload).not.toHaveProperty("agentId");
|
||||
expect(items[0].payload).not.toHaveProperty("runId");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -166,7 +166,7 @@ describe("Valkey – mocked iovalkey client", () => {
|
||||
const result = await store.get("mem-1");
|
||||
expect(result?.id).toBe("mem-1");
|
||||
expect(result?.payload.data).toBe("hello valkey");
|
||||
expect(result?.payload.userId).toBe("alice");
|
||||
expect(result?.payload.user_id).toBe("alice");
|
||||
// created_at is persisted as unix seconds and rendered back to its ISO instant.
|
||||
expect(result?.payload.createdAt).toBe("2024-01-01T00:00:00.000Z");
|
||||
});
|
||||
@@ -244,6 +244,91 @@ describe("Valkey – mocked iovalkey client", () => {
|
||||
expect(searchCall[2]).toContain("@user_id:{a\\|b\\ c}");
|
||||
});
|
||||
|
||||
it("get returns entity ids as snake_case", async () => {
|
||||
const store = new ValkeyDB({
|
||||
collectionName: "test",
|
||||
embeddingModelDims: 4,
|
||||
valkeyUrl: "valkey://localhost:6379",
|
||||
});
|
||||
await store.initialize();
|
||||
|
||||
await store.insert(
|
||||
[[0.1, 0.2, 0.3, 0.4]],
|
||||
["mem-ids"],
|
||||
[
|
||||
{
|
||||
data: "likes tea",
|
||||
user_id: "user-1",
|
||||
agent_id: "agent-1",
|
||||
run_id: "run-1",
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
const result = await store.get("mem-ids");
|
||||
expect(result?.payload.user_id).toBe("user-1");
|
||||
expect(result?.payload.agent_id).toBe("agent-1");
|
||||
expect(result?.payload.run_id).toBe("run-1");
|
||||
expect(result?.payload).not.toHaveProperty("userId");
|
||||
expect(result?.payload).not.toHaveProperty("agentId");
|
||||
expect(result?.payload).not.toHaveProperty("runId");
|
||||
});
|
||||
|
||||
it("search and list return entity ids as snake_case", async () => {
|
||||
const store = new ValkeyDB({
|
||||
collectionName: "test",
|
||||
embeddingModelDims: 4,
|
||||
valkeyUrl: "valkey://localhost:6379",
|
||||
});
|
||||
await store.initialize();
|
||||
|
||||
const iovalkey = require("iovalkey");
|
||||
const mockClient = iovalkey.__mockClient;
|
||||
mockClient.call.mockImplementation(async (...args: any[]) => {
|
||||
if (args[0] === "FT.SEARCH") {
|
||||
return [
|
||||
1,
|
||||
"mem0:test:mem-search",
|
||||
[
|
||||
"memory_id",
|
||||
"mem-search",
|
||||
"hash",
|
||||
"h1",
|
||||
"memory",
|
||||
"likes tea",
|
||||
"created_at",
|
||||
"1700000000",
|
||||
"agent_id",
|
||||
"agent-1",
|
||||
"run_id",
|
||||
"run-1",
|
||||
"user_id",
|
||||
"user-1",
|
||||
"metadata",
|
||||
"{}",
|
||||
"vector_score",
|
||||
"0.1",
|
||||
],
|
||||
];
|
||||
}
|
||||
return "OK";
|
||||
});
|
||||
|
||||
const results = await store.search([0.1, 0.2, 0.3, 0.4], 5);
|
||||
expect(results[0].payload.user_id).toBe("user-1");
|
||||
expect(results[0].payload.agent_id).toBe("agent-1");
|
||||
expect(results[0].payload.run_id).toBe("run-1");
|
||||
expect(results[0].payload).not.toHaveProperty("userId");
|
||||
expect(results[0].payload).not.toHaveProperty("agentId");
|
||||
expect(results[0].payload).not.toHaveProperty("runId");
|
||||
|
||||
const [listed] = await store.list();
|
||||
expect(listed[0].payload.user_id).toBe("user-1");
|
||||
expect(listed[0].payload.agent_id).toBe("agent-1");
|
||||
expect(listed[0].payload.run_id).toBe("run-1");
|
||||
expect(listed[0].payload).not.toHaveProperty("userId");
|
||||
});
|
||||
|
||||
it("does not raise an unhandled rejection when initialization fails", async () => {
|
||||
const iovalkey = require("iovalkey");
|
||||
iovalkey.__mockClient.call.mockImplementationOnce(async () => {
|
||||
|
||||
Reference in New Issue
Block a user