From fad0e0e415fda052d0b23159ffc53d9a6ea77a33 Mon Sep 17 00:00:00 2001 From: pratik <10096516+pratikgajjar@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:31:18 -0700 Subject: [PATCH] fix(ts-sdk): await identity before building project-scoped URLs (#6802) --- mem0-ts/src/client/mem0.ts | 26 +- .../tests/memoryClient.identity.test.ts | 228 ++++++++++++++++++ 2 files changed, 251 insertions(+), 3 deletions(-) create mode 100644 mem0-ts/src/client/tests/memoryClient.identity.test.ts diff --git a/mem0-ts/src/client/mem0.ts b/mem0-ts/src/client/mem0.ts index a521e2485..dd638bbc6 100644 --- a/mem0-ts/src/client/mem0.ts +++ b/mem0-ts/src/client/mem0.ts @@ -81,6 +81,8 @@ class APIError extends Error { interface ClientOptions { apiKey: string; host?: string; + /** Max cached identities per process. Defaults to 50. */ + identityCacheMax?: number; } interface ClientIdentity { @@ -90,7 +92,7 @@ interface ClientIdentity { } // 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>(); export default class MemoryClient { @@ -102,6 +104,7 @@ export default class MemoryClient { client: any; telemetryId: string; private initialized: Promise; + private identityCacheMax: number; _validateApiKey(): any { if (!this.apiKey) { @@ -120,6 +123,8 @@ export default class MemoryClient { this.host = options.host || "https://api.mem0.ai"; this.organizationId = null; this.projectId = null; + this.identityCacheMax = + options.identityCacheMax ?? IDENTITY_CACHE_MAX_DEFAULT; this.headers = { Authorization: `Token ${this.apiKey}`, @@ -136,7 +141,7 @@ export default class MemoryClient { this.telemetryId = ""; - // Requests never wait on this; only telemetry does. + // Memory requests never wait on this; telemetry and _awaitIdentity do. this.initialized = this._resolveIdentity(); } @@ -146,7 +151,7 @@ export default class MemoryClient { let shared = identityByCredentials.get(credentials); if (!shared) { shared = this._initializeClient(); - if (identityByCredentials.size >= IDENTITY_CACHE_MAX) { + if (identityByCredentials.size >= this.identityCacheMax) { identityByCredentials.delete( identityByCredentials.keys().next().value!, ); @@ -165,6 +170,11 @@ export default class MemoryClient { }); } + // Blocks until the ping has populated organizationId/projectId. + private async _awaitIdentity(): Promise { + await this.initialized; + } + private async _initializeClient(): Promise { try { await this.ping(); @@ -616,6 +626,7 @@ export default class MemoryClient { const payloadKeys = Object.keys(options || {}); this._captureEvent("get_project", [payloadKeys]); const { fields } = options; + await this._awaitIdentity(); if (!(this.organizationId && this.projectId)) { throw new Error( @@ -639,6 +650,7 @@ export default class MemoryClient { prompts: PromptUpdatePayload, ): Promise> { this._captureEvent("update_project", []); + await this._awaitIdentity(); if (!(this.organizationId && this.projectId)) { throw new Error( "organizationId and projectId must be set to update instructions or categories", @@ -659,7 +671,11 @@ export default class MemoryClient { // WebHooks async getWebhooks(data?: { projectId?: string }): Promise> { this._captureEvent("get_webhooks", []); + if (!data?.projectId) await this._awaitIdentity(); 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( `${this.host}/api/v1/webhooks/projects/${project_id}/`, { @@ -671,6 +687,10 @@ export default class MemoryClient { async createWebhook(webhook: WebhookCreatePayload): Promise { this._captureEvent("create_webhook", []); + await this._awaitIdentity(); + if (!this.projectId) { + throw new Error("projectId must be set to create a webhook"); + } const body = { name: webhook.name, url: webhook.url, diff --git a/mem0-ts/src/client/tests/memoryClient.identity.test.ts b/mem0-ts/src/client/tests/memoryClient.identity.test.ts new file mode 100644 index 000000000..ac77e2356 --- /dev/null +++ b/mem0-ts/src/client/tests/memoryClient.identity.test.ts @@ -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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + }); +});