From fb6b5ced9afbf65b2edb9b3515d3c0b5e515e424 Mon Sep 17 00:00:00 2001 From: Atharv Mantri Date: Mon, 14 Sep 2026 15:39:15 +0530 Subject: [PATCH] feat: implement TypeScript SDK remote client --- packages/sdk-ts/src/index.test.ts | 168 ++++++++++++++++++++++++++++++ packages/sdk-ts/src/index.ts | 136 ++++++++++++++++++++++-- 2 files changed, 293 insertions(+), 11 deletions(-) diff --git a/packages/sdk-ts/src/index.test.ts b/packages/sdk-ts/src/index.test.ts index ba90387..4f9947a 100644 --- a/packages/sdk-ts/src/index.test.ts +++ b/packages/sdk-ts/src/index.test.ts @@ -29,6 +29,9 @@ import { Scope, Sensitivity, MemoryStatus, + MemoryGuard, + MemoryGuardError, + type FetchLike, // request models + serializers AddMemoryRequest, QueryRequest, @@ -432,3 +435,168 @@ describe("serialization round-trip — concrete example", () => { expect(response.queryId).toBe("22222222-2222-4222-8222-222222222222"); }); }); + +// --------------------------------------------------------------------------- +// Remote client transport +// --------------------------------------------------------------------------- + +describe("remote client", () => { + const memoryWire: MemoryWire = { + memory_id: "11111111-1111-4111-8111-111111111111", + content: "billing-svc uses PostgreSQL 15", + source_type: SourceType.File, + source_ref: "repo://billing-svc/README.md@c4a1", + scope: Scope.Repo, + scope_ref: "billing-svc", + created_at: "2024-01-01T00:00:00.000Z", + updated_at: "2024-01-01T00:00:00.000Z", + expires_at: null, + trust_score: 0.82, + sensitivity: Sensitivity.Internal, + status: MemoryStatus.Active, + contradicts: [], + tags: ["db"], + }; + + function jsonResponse(payload: unknown, status = 200): Response { + return new Response( + payload === undefined ? null : JSON.stringify(payload), + { status, headers: { "content-type": "application/json" } }, + ); + } + + it("serializes requests, maps every endpoint, and sends bearer auth", async () => { + const calls: Array<{ input: string; init?: RequestInit }> = []; + const responses = [ + jsonResponse({ memory: memoryWire }), + jsonResponse({ memory: memoryWire }), + jsonResponse({ + query: { + results: [ + { + memory: memoryWire, + relevance: 0.9, + final_rank: 0.88, + reasons: ["recent"], + }, + ], + query_id: "22222222-2222-4222-8222-222222222222", + }, + }), + jsonResponse({ result: { created: 1, memory_ids: [memoryWire.memory_id] } }), + jsonResponse({ data: { memory: memoryWire } }), + jsonResponse(undefined, 204), + jsonResponse({ + contradictions: [ + { + memory_id: "33333333-3333-4333-8333-333333333333", + source_ref: "repo://billing-svc/old.md", + status: MemoryStatus.Superseded, + reason: "superseded decision", + confidence: 0.91, + }, + ], + }), + ]; + const fetchImpl: FetchLike = async (input, init) => { + calls.push({ input, init }); + return responses.shift() ?? jsonResponse({ error: "unexpected call" }, 500); + }; + + const client = MemoryGuard.remote({ + baseUrl: "https://api.example.test///", + token: "test-token", + fetch: fetchImpl, + }); + + const added = await client.add({ + content: memoryWire.content, + sourceType: SourceType.File, + sourceRef: memoryWire.source_ref, + scope: Scope.Repo, + scopeRef: memoryWire.scope_ref, + sensitivity: Sensitivity.Internal, + tags: ["db"], + }); + expect(added.memoryId).toBe(memoryWire.memory_id); + expect(calls[0].input).toBe("https://api.example.test/v1/memories"); + expect(calls[0].init?.method).toBe("POST"); + expect(calls[0].init?.headers).toEqual({ + Accept: "application/json", + "Content-Type": "application/json", + Authorization: "Bearer test-token", + }); + expect(JSON.parse(calls[0].init?.body as string)).toEqual({ + content: memoryWire.content, + source_type: SourceType.File, + source_ref: memoryWire.source_ref, + scope: Scope.Repo, + scope_ref: memoryWire.scope_ref, + sensitivity: Sensitivity.Internal, + tags: ["db"], + }); + + expect((await client.get("m/1")).content).toBe(memoryWire.content); + expect(calls[1].input).toBe("https://api.example.test/v1/memories/m%2F1"); + + const results = await client.query({ + text: "which database?", + scope: Scope.Repo, + scopeRef: "billing-svc", + minTrust: 0.5, + limit: 5, + }); + expect(results[0].memory.trustScore).toBe(0.82); + expect(results[0].reasons).toEqual(["recent"]); + expect(JSON.parse(calls[2].init?.body as string)).toEqual({ + text: "which database?", + scope: Scope.Repo, + scope_ref: "billing-svc", + min_trust: 0.5, + limit: 5, + }); + + await expect( + client.ingestPath({ path: "./docs", scope: Scope.Repo }), + ).resolves.toEqual({ created: 1, memoryIds: [memoryWire.memory_id] }); + await expect(client.correct("m/1", "new content")).resolves.toMatchObject({ + memoryId: memoryWire.memory_id, + }); + expect(JSON.parse(calls[4].init?.body as string)).toEqual({ + content: "new content", + }); + await expect(client.delete("m/1")).resolves.toBeUndefined(); + expect(calls[5].init?.headers).toEqual({ + Accept: "application/json", + Authorization: "Bearer test-token", + }); + + await expect(client.contradictions(memoryWire.memory_id)).resolves.toEqual([ + { + memoryId: "33333333-3333-4333-8333-333333333333", + sourceRef: "repo://billing-svc/old.md", + status: MemoryStatus.Superseded, + reason: "superseded decision", + confidence: 0.91, + }, + ]); + }); + + it("maps non-2xx JSON responses to MemoryGuardError", async () => { + const fetchImpl: FetchLike = async () => + jsonResponse({ detail: "memory does not exist" }, 404); + const client = MemoryGuard.remote({ + baseUrl: "https://api.example.test", + fetch: fetchImpl, + }); + + await expect(client.get("missing")).rejects.toEqual( + expect.objectContaining({ + name: "MemoryGuardError", + status: 404, + message: "memory does not exist", + body: { detail: "memory does not exist" }, + } satisfies Partial), + ); + }); +}); diff --git a/packages/sdk-ts/src/index.ts b/packages/sdk-ts/src/index.ts index aa355fe..e5bc5a5 100644 --- a/packages/sdk-ts/src/index.ts +++ b/packages/sdk-ts/src/index.ts @@ -14,10 +14,10 @@ * The mapping between the two is performed by the request serialization / * response deserialization layer. * - * Scaffold status (task 24.1): this module defines the full type surface and - * method signatures. Method bodies are `TODO` stubs that throw - * `Error("TODO: not implemented")`; the real REST client implementation - * (fetch, serialization, error mapping) lands in task 24.2. + * The client uses the platform `fetch` API and keeps transport concerns small: + * request bodies are serialized to the REST API's snake_case wire format, + * response envelopes are accepted in either wrapped or bare form, and + * non-2xx responses become `MemoryGuardError` instances with their payload. * * Requirements: 12.1 (remote constructor targeting the REST API base URL with * an optional auth token), 12.2 (exposes add/get/query/ingestPath/correct/ @@ -456,6 +456,48 @@ export function deserializeContradiction(wire: ContradictionWire): Contradiction }; } +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function unwrapObject(data: unknown, key: string): T { + if (!isRecord(data)) { + throw new Error(`MemoryGuard API returned an invalid ${key} response`); + } + const wrapped = data[key]; + if (isRecord(wrapped)) return wrapped as T; + for (const alternative of ["data", "result"]) { + if (isRecord(data[alternative])) { + return unwrapObject(data[alternative], key); + } + } + return data as T; +} + +function unwrapList(data: unknown, key: string): T[] { + if (Array.isArray(data)) return data as T[]; + if (isRecord(data)) { + for (const candidate of [key, "results", "items", "data"]) { + if (Array.isArray(data[candidate])) return data[candidate] as T[]; + } + } + throw new Error(`MemoryGuard API returned an invalid ${key} response`); +} + +function errorMessage(payload: unknown): string { + if (isRecord(payload)) { + for (const key of ["error", "detail", "message"]) { + if (payload[key] !== undefined) return String(payload[key]); + } + } + if (typeof payload === "string") return payload; + try { + return JSON.stringify(payload); + } catch { + return String(payload); + } +} + // --------------------------------------------------------------------------- // Client // --------------------------------------------------------------------------- @@ -516,13 +558,53 @@ export class MemoryGuard { return new MemoryGuard(options); } + private async request( + method: string, + path: string, + body?: Record, + ): Promise { + const headers: Record = { + Accept: "application/json", + }; + if (body !== undefined) headers["Content-Type"] = "application/json"; + if (this.token) headers.Authorization = `Bearer ${this.token}`; + + const init: RequestInit = { method, headers }; + if (body !== undefined) init.body = JSON.stringify(body); + + const response = await this.fetchImpl(`${this.baseUrl}${path}`, init); + const raw = await response.text(); + let payload: unknown; + if (raw) { + try { + payload = JSON.parse(raw); + } catch { + payload = raw; + } + } + + if (!response.ok) { + throw new MemoryGuardError( + response.status, + errorMessage(payload), + payload, + ); + } + return payload as T; + } + /** * Create a memory with the supplied provenance and lifecycle metadata. * * Maps to `POST /v1/memories` (`CreateMemoryRequest` -> `MemoryResponse`). */ async add(request: AddMemoryRequest): Promise { - throw new Error("TODO: not implemented"); + const payload = await this.request( + "POST", + "/v1/memories", + serializeAddRequest(request) as unknown as Record, + ); + return deserializeMemory(unwrapObject(payload, "memory")); } /** @@ -532,7 +614,11 @@ export class MemoryGuard { * memory surfaces as a {@link MemoryGuardError} with status `404`. */ async get(memoryId: string): Promise { - throw new Error("TODO: not implemented"); + const payload = await this.request( + "GET", + `/v1/memories/${encodeURIComponent(memoryId)}`, + ); + return deserializeMemory(unwrapObject(payload, "memory")); } /** @@ -543,7 +629,14 @@ export class MemoryGuard { * `sourceRef`, and `reasons` (Requirement 12.3). */ async query(request: QueryRequest): Promise { - throw new Error("TODO: not implemented"); + const payload = await this.request( + "POST", + "/v1/query", + serializeQueryRequest(request) as unknown as Record, + ); + return deserializeQueryResponse( + unwrapObject(payload, "query"), + ).results; } /** @@ -553,7 +646,14 @@ export class MemoryGuard { * `IngestPathResponse`). */ async ingestPath(request: IngestPathRequest): Promise { - throw new Error("TODO: not implemented"); + const payload = await this.request( + "POST", + "/v1/ingest/path", + serializeIngestPathRequest(request) as unknown as Record, + ); + return deserializeIngestPathResult( + unwrapObject(payload, "result"), + ); } /** @@ -563,7 +663,12 @@ export class MemoryGuard { * `MemoryResponse`). The prior record becomes `corrected`. */ async correct(memoryId: string, content: string): Promise { - throw new Error("TODO: not implemented"); + const payload = await this.request( + "PATCH", + `/v1/memories/${encodeURIComponent(memoryId)}`, + { content }, + ); + return deserializeMemory(unwrapObject(payload, "memory")); } /** @@ -572,7 +677,10 @@ export class MemoryGuard { * Maps to `DELETE /v1/memories/{memory_id}`. */ async delete(memoryId: string): Promise { - throw new Error("TODO: not implemented"); + await this.request( + "DELETE", + `/v1/memories/${encodeURIComponent(memoryId)}`, + ); } /** @@ -582,7 +690,13 @@ export class MemoryGuard { * `ContradictionResponse[]`). */ async contradictions(memoryId: string): Promise { - throw new Error("TODO: not implemented"); + const payload = await this.request( + "GET", + `/v1/memories/${encodeURIComponent(memoryId)}/contradictions`, + ); + return unwrapList(payload, "contradictions").map( + deserializeContradiction, + ); } }