fix(ts-sdk): await identity before building project-scoped URLs (#6802)
This commit is contained in:
@@ -81,6 +81,8 @@ class APIError extends Error {
|
|||||||
interface ClientOptions {
|
interface ClientOptions {
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
host?: string;
|
host?: string;
|
||||||
|
/** Max cached identities per process. Defaults to 50. */
|
||||||
|
identityCacheMax?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ClientIdentity {
|
interface ClientIdentity {
|
||||||
@@ -90,7 +92,7 @@ interface ClientIdentity {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Shares one ping per (host, api key) across clients; FIFO-capped.
|
// Shares one ping per (host, api key) across clients; FIFO-capped.
|
||||||
const IDENTITY_CACHE_MAX = 50;
|
const IDENTITY_CACHE_MAX_DEFAULT = 50;
|
||||||
const identityByCredentials = new Map<string, Promise<ClientIdentity>>();
|
const identityByCredentials = new Map<string, Promise<ClientIdentity>>();
|
||||||
|
|
||||||
export default class MemoryClient {
|
export default class MemoryClient {
|
||||||
@@ -102,6 +104,7 @@ export default class MemoryClient {
|
|||||||
client: any;
|
client: any;
|
||||||
telemetryId: string;
|
telemetryId: string;
|
||||||
private initialized: Promise<void>;
|
private initialized: Promise<void>;
|
||||||
|
private identityCacheMax: number;
|
||||||
|
|
||||||
_validateApiKey(): any {
|
_validateApiKey(): any {
|
||||||
if (!this.apiKey) {
|
if (!this.apiKey) {
|
||||||
@@ -120,6 +123,8 @@ export default class MemoryClient {
|
|||||||
this.host = options.host || "https://api.mem0.ai";
|
this.host = options.host || "https://api.mem0.ai";
|
||||||
this.organizationId = null;
|
this.organizationId = null;
|
||||||
this.projectId = null;
|
this.projectId = null;
|
||||||
|
this.identityCacheMax =
|
||||||
|
options.identityCacheMax ?? IDENTITY_CACHE_MAX_DEFAULT;
|
||||||
|
|
||||||
this.headers = {
|
this.headers = {
|
||||||
Authorization: `Token ${this.apiKey}`,
|
Authorization: `Token ${this.apiKey}`,
|
||||||
@@ -136,7 +141,7 @@ export default class MemoryClient {
|
|||||||
|
|
||||||
this.telemetryId = "";
|
this.telemetryId = "";
|
||||||
|
|
||||||
// Requests never wait on this; only telemetry does.
|
// Memory requests never wait on this; telemetry and _awaitIdentity do.
|
||||||
this.initialized = this._resolveIdentity();
|
this.initialized = this._resolveIdentity();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,7 +151,7 @@ export default class MemoryClient {
|
|||||||
let shared = identityByCredentials.get(credentials);
|
let shared = identityByCredentials.get(credentials);
|
||||||
if (!shared) {
|
if (!shared) {
|
||||||
shared = this._initializeClient();
|
shared = this._initializeClient();
|
||||||
if (identityByCredentials.size >= IDENTITY_CACHE_MAX) {
|
if (identityByCredentials.size >= this.identityCacheMax) {
|
||||||
identityByCredentials.delete(
|
identityByCredentials.delete(
|
||||||
identityByCredentials.keys().next().value!,
|
identityByCredentials.keys().next().value!,
|
||||||
);
|
);
|
||||||
@@ -165,6 +170,11 @@ export default class MemoryClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Blocks until the ping has populated organizationId/projectId.
|
||||||
|
private async _awaitIdentity(): Promise<void> {
|
||||||
|
await this.initialized;
|
||||||
|
}
|
||||||
|
|
||||||
private async _initializeClient(): Promise<ClientIdentity> {
|
private async _initializeClient(): Promise<ClientIdentity> {
|
||||||
try {
|
try {
|
||||||
await this.ping();
|
await this.ping();
|
||||||
@@ -616,6 +626,7 @@ export default class MemoryClient {
|
|||||||
const payloadKeys = Object.keys(options || {});
|
const payloadKeys = Object.keys(options || {});
|
||||||
this._captureEvent("get_project", [payloadKeys]);
|
this._captureEvent("get_project", [payloadKeys]);
|
||||||
const { fields } = options;
|
const { fields } = options;
|
||||||
|
await this._awaitIdentity();
|
||||||
|
|
||||||
if (!(this.organizationId && this.projectId)) {
|
if (!(this.organizationId && this.projectId)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -639,6 +650,7 @@ export default class MemoryClient {
|
|||||||
prompts: PromptUpdatePayload,
|
prompts: PromptUpdatePayload,
|
||||||
): Promise<Record<string, any>> {
|
): Promise<Record<string, any>> {
|
||||||
this._captureEvent("update_project", []);
|
this._captureEvent("update_project", []);
|
||||||
|
await this._awaitIdentity();
|
||||||
if (!(this.organizationId && this.projectId)) {
|
if (!(this.organizationId && this.projectId)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"organizationId and projectId must be set to update instructions or categories",
|
"organizationId and projectId must be set to update instructions or categories",
|
||||||
@@ -659,7 +671,11 @@ export default class MemoryClient {
|
|||||||
// WebHooks
|
// WebHooks
|
||||||
async getWebhooks(data?: { projectId?: string }): Promise<Array<Webhook>> {
|
async getWebhooks(data?: { projectId?: string }): Promise<Array<Webhook>> {
|
||||||
this._captureEvent("get_webhooks", []);
|
this._captureEvent("get_webhooks", []);
|
||||||
|
if (!data?.projectId) await this._awaitIdentity();
|
||||||
const project_id = data?.projectId || this.projectId;
|
const project_id = data?.projectId || this.projectId;
|
||||||
|
if (!project_id) {
|
||||||
|
throw new Error("projectId must be set to access webhooks");
|
||||||
|
}
|
||||||
const response = await this._fetchWithErrorHandling(
|
const response = await this._fetchWithErrorHandling(
|
||||||
`${this.host}/api/v1/webhooks/projects/${project_id}/`,
|
`${this.host}/api/v1/webhooks/projects/${project_id}/`,
|
||||||
{
|
{
|
||||||
@@ -671,6 +687,10 @@ export default class MemoryClient {
|
|||||||
|
|
||||||
async createWebhook(webhook: WebhookCreatePayload): Promise<Webhook> {
|
async createWebhook(webhook: WebhookCreatePayload): Promise<Webhook> {
|
||||||
this._captureEvent("create_webhook", []);
|
this._captureEvent("create_webhook", []);
|
||||||
|
await this._awaitIdentity();
|
||||||
|
if (!this.projectId) {
|
||||||
|
throw new Error("projectId must be set to create a webhook");
|
||||||
|
}
|
||||||
const body = {
|
const body = {
|
||||||
name: webhook.name,
|
name: webhook.name,
|
||||||
url: webhook.url,
|
url: webhook.url,
|
||||||
|
|||||||
@@ -0,0 +1,228 @@
|
|||||||
|
/**
|
||||||
|
* MemoryClient unit tests — org/project identity resolution ordering.
|
||||||
|
*
|
||||||
|
* Each test calls the method under test as the first awaited operation on a
|
||||||
|
* fresh client. Any earlier await on the instance resolves identity and voids
|
||||||
|
* the test.
|
||||||
|
*/
|
||||||
|
import { MemoryClient } from "../mem0";
|
||||||
|
import { TEST_ORG_ID, TEST_PROJECT_ID } from "./helpers";
|
||||||
|
import { setupMockFetch, installConsoleSuppression } from "./setup";
|
||||||
|
|
||||||
|
installConsoleSuppression();
|
||||||
|
|
||||||
|
// Distinct key per client keeps each test off the module-scope identity cache.
|
||||||
|
let keySeq = 0;
|
||||||
|
const freshClient = () =>
|
||||||
|
new MemoryClient({ apiKey: `test-api-key-identity-${keySeq++}` });
|
||||||
|
|
||||||
|
// Selects by path; the ping and PostHog calls also land in the mock.
|
||||||
|
const findUrlOrNone = (mock: jest.Mock, needle: string): string | undefined =>
|
||||||
|
mock.mock.calls
|
||||||
|
.map((c: [string, RequestInit]) => c[0])
|
||||||
|
.find((u: string) => u.includes(needle));
|
||||||
|
|
||||||
|
const findUrl = (mock: jest.Mock, needle: string): string => {
|
||||||
|
const url = findUrlOrNone(mock, needle);
|
||||||
|
expect(url).toBeDefined();
|
||||||
|
return url as string;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("MemoryClient - project-scoped URLs on a fresh client", () => {
|
||||||
|
test("getProject uses the resolved org and project ids", async () => {
|
||||||
|
const extra = new Map<string, { status: number; body: unknown }>();
|
||||||
|
extra.set("/api/v1/orgs/organizations/", {
|
||||||
|
status: 200,
|
||||||
|
body: { custom_instructions: "Be helpful" },
|
||||||
|
});
|
||||||
|
const mock = setupMockFetch(extra);
|
||||||
|
|
||||||
|
await freshClient().getProject({ fields: ["custom_instructions"] });
|
||||||
|
|
||||||
|
const url = findUrl(mock, "/api/v1/orgs/organizations/");
|
||||||
|
expect(url).toContain(`/api/v1/orgs/organizations/${TEST_ORG_ID}/`);
|
||||||
|
expect(url).toContain(`/projects/${TEST_PROJECT_ID}/`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("updateProject uses the resolved org and project ids", async () => {
|
||||||
|
const extra = new Map<string, { status: number; body: unknown }>();
|
||||||
|
extra.set("/api/v1/orgs/organizations/", {
|
||||||
|
status: 200,
|
||||||
|
body: { custom_instructions: "Updated" },
|
||||||
|
});
|
||||||
|
const mock = setupMockFetch(extra);
|
||||||
|
|
||||||
|
await freshClient().updateProject({ customInstructions: "Updated" });
|
||||||
|
|
||||||
|
const url = findUrl(mock, "/api/v1/orgs/organizations/");
|
||||||
|
expect(url).toContain(`/api/v1/orgs/organizations/${TEST_ORG_ID}/`);
|
||||||
|
expect(url).toContain(`/projects/${TEST_PROJECT_ID}/`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getWebhooks targets the resolved project, not null", async () => {
|
||||||
|
const extra = new Map<string, { status: number; body: unknown }>();
|
||||||
|
extra.set("/api/v1/webhooks/projects/", { status: 200, body: [] });
|
||||||
|
const mock = setupMockFetch(extra);
|
||||||
|
|
||||||
|
await freshClient().getWebhooks();
|
||||||
|
|
||||||
|
const url = findUrl(mock, "/api/v1/webhooks/projects/");
|
||||||
|
expect(url).toContain(`/api/v1/webhooks/projects/${TEST_PROJECT_ID}/`);
|
||||||
|
expect(url).not.toContain("/projects/null/");
|
||||||
|
expect(url).not.toContain("/projects/undefined/");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("createWebhook targets the resolved project, not null", async () => {
|
||||||
|
const extra = new Map<string, { status: number; body: unknown }>();
|
||||||
|
extra.set("/api/v1/webhooks/projects/", {
|
||||||
|
status: 200,
|
||||||
|
body: { webhook_id: "wh_1" },
|
||||||
|
});
|
||||||
|
const mock = setupMockFetch(extra);
|
||||||
|
|
||||||
|
await freshClient().createWebhook({
|
||||||
|
name: "hook",
|
||||||
|
url: "https://example.com/hook",
|
||||||
|
eventTypes: ["memory_add"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const url = findUrl(mock, "/api/v1/webhooks/projects/");
|
||||||
|
expect(url).toContain(`/api/v1/webhooks/projects/${TEST_PROJECT_ID}/`);
|
||||||
|
expect(url).not.toContain("/projects/null/");
|
||||||
|
expect(url).not.toContain("/projects/undefined/");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an explicit projectId is honored without waiting on identity", async () => {
|
||||||
|
const extra = new Map<string, { status: number; body: unknown }>();
|
||||||
|
extra.set("/api/v1/webhooks/projects/", { status: 200, body: [] });
|
||||||
|
const mock = setupMockFetch(extra);
|
||||||
|
|
||||||
|
await freshClient().getWebhooks({ projectId: "proj_explicit_789" });
|
||||||
|
|
||||||
|
expect(findUrl(mock, "/api/v1/webhooks/projects/")).toContain(
|
||||||
|
"/api/v1/webhooks/projects/proj_explicit_789/",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("MemoryClient - memory endpoints do not wait on identity", () => {
|
||||||
|
test("search completes while the ping is still pending", async () => {
|
||||||
|
// The ping never settles.
|
||||||
|
const mock = jest.fn((url: string) => {
|
||||||
|
if (url.includes("/v1/ping/")) return new Promise(() => {});
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: () => Promise.resolve({ results: [] }),
|
||||||
|
text: () => Promise.resolve(""),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
global.fetch = mock as unknown as typeof global.fetch;
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
freshClient().search("query", { filters: { user_id: "alice" } }),
|
||||||
|
).resolves.toBeDefined();
|
||||||
|
|
||||||
|
expect(findUrl(mock, "/v3/memories/search/")).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("MemoryClient - identity cache overflow", () => {
|
||||||
|
const pingCount = (mock: jest.Mock) =>
|
||||||
|
mock.mock.calls.filter((c: [string, RequestInit]) =>
|
||||||
|
c[0].includes("/v1/ping/"),
|
||||||
|
).length;
|
||||||
|
|
||||||
|
// The cache is module scope, so each test needs its own module registry.
|
||||||
|
const isolatedClient = (): typeof MemoryClient => {
|
||||||
|
jest.resetModules();
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
|
return require("../mem0").MemoryClient;
|
||||||
|
};
|
||||||
|
|
||||||
|
const projectOk = () => {
|
||||||
|
const extra = new Map<string, { status: number; body: unknown }>();
|
||||||
|
extra.set("/api/v1/orgs/organizations/", { status: 200, body: {} });
|
||||||
|
return setupMockFetch(extra);
|
||||||
|
};
|
||||||
|
|
||||||
|
test("a reused credential pair pings once", async () => {
|
||||||
|
const mock = projectOk();
|
||||||
|
const Client = isolatedClient();
|
||||||
|
|
||||||
|
const opts = { apiKey: "cache-warm-key", identityCacheMax: 1 };
|
||||||
|
await new Client(opts).getProject({ fields: [] });
|
||||||
|
await new Client(opts).getProject({ fields: [] });
|
||||||
|
|
||||||
|
expect(pingCount(mock)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an evicted credential pair re-pings and still resolves identity", async () => {
|
||||||
|
const mock = projectOk();
|
||||||
|
const Client = isolatedClient();
|
||||||
|
const opts = (apiKey: string) => ({ apiKey, identityCacheMax: 1 });
|
||||||
|
|
||||||
|
await new Client(opts("key-a")).getProject({ fields: [] });
|
||||||
|
// Evicts key-a.
|
||||||
|
await new Client(opts("key-b")).getProject({ fields: [] });
|
||||||
|
expect(pingCount(mock)).toBe(2);
|
||||||
|
|
||||||
|
await new Client(opts("key-a")).getProject({ fields: [] });
|
||||||
|
|
||||||
|
// Eviction costs a ping; it never yields an unresolved identity.
|
||||||
|
expect(pingCount(mock)).toBe(3);
|
||||||
|
expect(findUrl(mock, "/api/v1/orgs/organizations/")).toContain(
|
||||||
|
`/api/v1/orgs/organizations/${TEST_ORG_ID}/`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("MemoryClient - unresolved identity", () => {
|
||||||
|
const pingFails = () => {
|
||||||
|
const mock = jest.fn((url: string) => {
|
||||||
|
if (url.includes("/v1/ping/")) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: false,
|
||||||
|
status: 500,
|
||||||
|
text: () => Promise.resolve("boom"),
|
||||||
|
json: () => Promise.resolve({}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: () => Promise.resolve([]),
|
||||||
|
text: () => Promise.resolve(""),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
global.fetch = mock as unknown as typeof global.fetch;
|
||||||
|
return mock;
|
||||||
|
};
|
||||||
|
|
||||||
|
test("getProject reports the unset ids", async () => {
|
||||||
|
pingFails();
|
||||||
|
await expect(freshClient().getProject({ fields: [] })).rejects.toThrow(
|
||||||
|
"organizationId and projectId must be set",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getWebhooks reports the unset project instead of requesting null", async () => {
|
||||||
|
const mock = pingFails();
|
||||||
|
await expect(freshClient().getWebhooks()).rejects.toThrow(
|
||||||
|
"projectId must be set",
|
||||||
|
);
|
||||||
|
expect(findUrlOrNone(mock, "/api/v1/webhooks/")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("createWebhook reports the unset project instead of requesting null", async () => {
|
||||||
|
const mock = pingFails();
|
||||||
|
await expect(
|
||||||
|
freshClient().createWebhook({
|
||||||
|
name: "hook",
|
||||||
|
url: "https://example.com/hook",
|
||||||
|
eventTypes: ["memory_add"],
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("projectId must be set");
|
||||||
|
expect(findUrlOrNone(mock, "/api/v1/webhooks/")).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user