fix(profiles): follow the jobs API

Four POST routes became one collection with the operation in the body, so the
three route pages collapse into one. samples became sample. Creates send an
Idempotency-Key and return a status_url to poll, which the client follows
rather than building the path.

Full rebuild is closed: regenerate_profiles answers 501 not_yet_available and
the docs say so instead of teaching a daily cadence that cannot run.
This commit is contained in:
Pratik
2026-09-16 17:02:10 -07:00
parent c4eea87526
commit 34894e2cea
14 changed files with 413 additions and 225 deletions
+40 -12
View File
@@ -22,6 +22,8 @@ import {
GetMemoryExportPayload,
ProfileEntityType,
ProfileResponse,
ProfileJobResponse,
ProfileJobStatus,
ProfileTriggerResponse,
ProfileSettings,
ProfileSamplesResponse,
@@ -98,6 +100,9 @@ interface ClientIdentity {
}
// Shares one ping per (host, api key) across clients; FIFO-capped.
// One collection for every generation; the operation is a body field.
const PROFILE_JOBS_PATH = "/v2/profiles/jobs/";
const IDENTITY_CACHE_MAX_DEFAULT = 50;
const identityByCredentials = new Map<string, Promise<ClientIdentity>>();
@@ -803,11 +808,12 @@ export default class MemoryClient {
await this._awaitIdentity();
const response = await this._fetchWithErrorHandling(
`${this.host}/v2/profiles/trigger/`,
`${this.host}${PROFILE_JOBS_PATH}`,
{
method: "POST",
headers: this.headers,
headers: { ...this.headers, "Idempotency-Key": crypto.randomUUID() },
body: JSON.stringify({
operation: "trigger",
entity_type: data.entityType ?? "user",
entity_id: data.entityId,
}),
@@ -862,20 +868,24 @@ export default class MemoryClient {
/**
* Generate profiles for a few real entities, to check a schema.
*
* Real generations against real memories, and the results are kept.
* Real generations against real memories, and the results are kept: the
* profiles are written to those entities and count toward usage.
*/
async sampleProfiles(data?: {
limit?: number;
}): Promise<ProfileSamplesResponse> {
}): Promise<ProfileJobResponse> {
this._captureEvent("sample_profiles", []);
await this._awaitIdentity();
const response = await this._fetchWithErrorHandling(
`${this.host}/v2/profiles/samples/`,
`${this.host}${PROFILE_JOBS_PATH}`,
{
method: "POST",
headers: this.headers,
body: JSON.stringify(this._prepareParams({ limit: data?.limit })),
headers: { ...this.headers, "Idempotency-Key": crypto.randomUUID() },
body: JSON.stringify({
operation: "sample",
...this._prepareParams({ limit: data?.limit }),
}),
},
);
return response;
@@ -884,23 +894,41 @@ export default class MemoryClient {
/**
* Rebuild the profile of every entity in the project.
*
* This is how a new schema reaches entities that already have a profile.
* Not available yet: the server answers 501 `not_yet_available` and creates
* nothing. Use {@link sampleProfiles} or {@link generateProfile} until
* `capabilities.full_rebuild` from {@link getProfileSettings} is true.
*/
async regenerateProfiles(): Promise<ProfileRegenerateResponse> {
async regenerateProfiles(): Promise<ProfileJobResponse> {
this._captureEvent("regenerate_profiles", []);
await this._awaitIdentity();
const response = await this._fetchWithErrorHandling(
`${this.host}/v2/profiles/regenerate/`,
`${this.host}${PROFILE_JOBS_PATH}`,
{
method: "POST",
headers: this.headers,
body: JSON.stringify({}),
headers: { ...this.headers, "Idempotency-Key": crypto.randomUUID() },
body: JSON.stringify({ operation: "regenerate" }),
},
);
return response;
}
/**
* Read one generation job. Accepts the `statusUrl` from a create call, or a
* bare job id. Prefer `statusUrl` so a route change needs no client update.
*/
async getProfileJob(jobIdOrStatusUrl: string): Promise<ProfileJobStatus> {
await this._awaitIdentity();
const path = jobIdOrStatusUrl.startsWith("/")
? jobIdOrStatusUrl
: `${PROFILE_JOBS_PATH}${jobIdOrStatusUrl}/`;
return this._fetchWithErrorHandling(`${this.host}${path}`, {
method: "GET",
headers: this.headers,
});
}
async createMemoryExport(
data: CreateMemoryExportPayload,
): Promise<{ message: string; id: string }> {
+38 -15
View File
@@ -256,14 +256,24 @@ export interface ProfileResponse {
generationCount: number;
}
export interface ProfileTriggerResponse {
message: string;
entityType: ProfileEntityType;
entityId: string;
profileId: string;
/** Every accepted generation. `statusUrl` is the server's own poll path. */
export interface ProfileJobResponse {
jobId: string;
status: string;
statusUrl: string;
operation: string;
entityType: ProfileEntityType;
usageUnits?: number;
eventId?: string;
replayed?: boolean;
/** Sample runs only. */
sampled?: number;
results?: Array<ProfileSampleResult>;
}
/** @deprecated Use {@link ProfileJobResponse}. */
export type ProfileTriggerResponse = ProfileJobResponse;
export interface ProfileSettings {
enabled?: boolean;
/** JSON Schema for the profile. Every property needs a `description`. */
@@ -278,15 +288,28 @@ export interface ProfileSampleResult {
[key: string]: any;
}
export interface ProfileSamplesResponse {
message: string;
sampled: number;
results: Array<ProfileSampleResult>;
}
/** @deprecated Use {@link ProfileJobResponse}. */
export type ProfileSamplesResponse = ProfileJobResponse;
export interface ProfileRegenerateResponse {
status: string;
message: string;
projectId: string;
existingProfileCount: number;
/** @deprecated Use {@link ProfileJobResponse}. */
export type ProfileRegenerateResponse = ProfileJobResponse;
/** `GET /v2/profiles/jobs/{id}/`. The job nests under `job`. */
export interface ProfileJobStatus {
job: {
id: string;
operation: string;
entityType: ProfileEntityType;
status: string;
/** Null until `enumerationComplete`. */
total: number | null;
enumerationComplete: boolean;
/** succeeded + failed + skipped. */
completed: number;
succeeded: number;
failed: number;
skipped: number;
results?: Array<ProfileSampleResult>;
[key: string]: any;
};
}
@@ -67,9 +67,9 @@ describe("MemoryClient - getProfile()", () => {
});
describe("MemoryClient - generateProfile()", () => {
test("posts entity_type and entity_id to the trigger route", async () => {
test("sends operation trigger with the entity to the jobs collection", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v2/profiles/trigger/", {
extra.set("/v2/profiles/jobs/", {
status: 202,
body: {
message: "Profile generation started.",
@@ -84,9 +84,10 @@ describe("MemoryClient - generateProfile()", () => {
const client = new MemoryClient({ apiKey: TEST_API_KEY });
const result = await client.generateProfile({ entityId: "alice" });
const call = findFetchCall(mock, "/v2/profiles/trigger/", "POST");
const call = findFetchCall(mock, "/v2/profiles/jobs/", "POST");
expect(call).toBeDefined();
const body = getFetchBody(call!);
expect(body.operation).toBe("trigger");
expect(body.entity_type).toBe("user");
expect(body.entity_id).toBe("alice");
expect(result.profileId).toBe("p_1");
@@ -184,44 +185,62 @@ describe("MemoryClient - profile settings", () => {
describe("MemoryClient - sampleProfiles() / regenerateProfiles()", () => {
test("sampleProfiles omits limit when unset", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v2/profiles/samples/", {
extra.set("/v2/profiles/jobs/", {
status: 202,
body: { message: "Sampling 5 users.", sampled: 5, results: [] },
body: {
job_id: "job_1",
status: "QUEUED",
status_url: "/v2/profiles/jobs/job_1/",
operation: "sample",
entity_type: "user",
sampled: 5,
results: [],
},
});
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
await client.sampleProfiles();
const call = findFetchCall(mock, "/v2/profiles/samples/", "POST");
expect(getFetchBody(call!)).toEqual({});
const call = findFetchCall(mock, "/v2/profiles/jobs/", "POST");
expect(getFetchBody(call!)).toEqual({ operation: "sample" });
});
test("sampleProfiles passes an explicit limit", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v2/profiles/samples/", {
extra.set("/v2/profiles/jobs/", {
status: 202,
body: { message: "Sampling 3 users.", sampled: 3, results: [] },
body: {
job_id: "job_2",
status: "QUEUED",
status_url: "/v2/profiles/jobs/job_2/",
operation: "sample",
entity_type: "user",
sampled: 3,
results: [],
},
});
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
const result = await client.sampleProfiles({ limit: 3 });
const call = findFetchCall(mock, "/v2/profiles/samples/", "POST");
const call = findFetchCall(mock, "/v2/profiles/jobs/", "POST");
expect(getFetchBody(call!).operation).toBe("sample");
expect(getFetchBody(call!).limit).toBe(3);
expect(result.sampled).toBe(3);
});
test("regenerateProfiles posts to the regenerate route", async () => {
test("sends operation regenerate to the jobs collection", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v2/profiles/regenerate/", {
extra.set("/v2/profiles/jobs/", {
status: 202,
body: {
status: "accepted",
message: "Regenerating profiles.",
project_id: "proj_abc",
existing_profile_count: 12,
job_id: "job_3",
status: "QUEUED",
status_url: "/v2/profiles/jobs/job_3/",
operation: "regenerate",
entity_type: "user",
},
});
const mock = setupMockFetch(extra);
@@ -230,9 +249,9 @@ describe("MemoryClient - sampleProfiles() / regenerateProfiles()", () => {
const result = await client.regenerateProfiles();
expect(
findFetchCall(mock, "/v2/profiles/regenerate/", "POST"),
findFetchCall(mock, "/v2/profiles/jobs/", "POST"),
).toBeDefined();
expect(result.existingProfileCount).toBe(12);
expect(result.projectId).toBe("proj_abc");
expect(result.jobId).toBe("job_3");
expect(result.statusUrl).toBe("/v2/profiles/jobs/job_3/");
});
});