fix(ts-sdk): await identity before building project-scoped URLs (#6802)

This commit is contained in:
pratik
2026-08-04 16:31:18 -07:00
committed by GitHub
parent 1112be3e5e
commit fad0e0e415
2 changed files with 251 additions and 3 deletions
+23 -3
View File
@@ -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<string, Promise<ClientIdentity>>();
export default class MemoryClient {
@@ -102,6 +104,7 @@ export default class MemoryClient {
client: any;
telemetryId: string;
private initialized: Promise<void>;
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<void> {
await this.initialized;
}
private async _initializeClient(): Promise<ClientIdentity> {
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<Record<string, any>> {
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<Array<Webhook>> {
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<Webhook> {
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,
@@ -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();
});
});