Merge commit 'deff028fed' as 'ai-context/trustgraph-client'

This commit is contained in:
elpresidank 2026-04-05 21:07:35 -05:00
commit 05d87964c2
27 changed files with 6278 additions and 0 deletions

View file

@ -0,0 +1,221 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { FlowsApi } from "../socket/trustgraph-socket";
import { FlowResponse } from "../models/messages";
describe("FlowsApi", () => {
let mockApi: {
makeRequest: ReturnType<typeof vi.fn>;
};
let flowsApi: FlowsApi;
beforeEach(() => {
mockApi = {
makeRequest: vi.fn(),
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
flowsApi = new FlowsApi(mockApi as any);
});
describe("startFlow", () => {
it("should call makeRequest with correct types and parameters", async () => {
const mockResponse: FlowResponse = {
flow: "started",
description: "Flow started successfully",
};
mockApi.makeRequest.mockResolvedValue(mockResponse);
const result = await flowsApi.startFlow(
"test-flow-id",
"test-class",
"Test description",
);
expect(mockApi.makeRequest).toHaveBeenCalledWith(
"flow",
{
operation: "start-flow",
"flow-id": "test-flow-id",
"blueprint-name": "test-class",
description: "Test description",
},
30000,
);
expect(result).toEqual(mockResponse);
});
it("should use FlowRequest and FlowResponse types", async () => {
const mockResponse: FlowResponse = {};
mockApi.makeRequest.mockResolvedValue(mockResponse);
await flowsApi.startFlow("id", "class", "desc");
// Verify the call signature matches FlowRequest/FlowResponse types
const callArgs = mockApi.makeRequest.mock.calls[0];
const request = callArgs[1];
// These properties should match FlowRequest interface
expect(request).toHaveProperty("operation");
expect(request).toHaveProperty("flow-id");
expect(request).toHaveProperty("blueprint-name");
expect(request).toHaveProperty("description");
});
});
describe("stopFlow", () => {
it("should call makeRequest with correct types and parameters", async () => {
const mockResponse: FlowResponse = {
flow: "stopped",
description: "Flow stopped successfully",
};
mockApi.makeRequest.mockResolvedValue(mockResponse);
const result = await flowsApi.stopFlow("test-flow-id");
expect(mockApi.makeRequest).toHaveBeenCalledWith(
"flow",
{
operation: "stop-flow",
"flow-id": "test-flow-id",
},
30000,
);
expect(result).toEqual(mockResponse);
});
it("should use FlowRequest and FlowResponse types", async () => {
const mockResponse: FlowResponse = {};
mockApi.makeRequest.mockResolvedValue(mockResponse);
await flowsApi.stopFlow("id");
// Verify the call signature matches FlowRequest/FlowResponse types
const callArgs = mockApi.makeRequest.mock.calls[0];
const request = callArgs[1];
// These properties should match FlowRequest interface
expect(request).toHaveProperty("operation");
expect(request).toHaveProperty("flow-id");
});
});
describe("getFlows", () => {
it("should return flow-ids array from response", async () => {
const mockResponse: FlowResponse = {
"flow-ids": ["flow1", "flow2", "flow3"],
};
mockApi.makeRequest.mockResolvedValue(mockResponse);
const result = await flowsApi.getFlows();
expect(mockApi.makeRequest).toHaveBeenCalledWith(
"flow",
{
operation: "list-flows",
},
60000,
);
expect(result).toEqual(["flow1", "flow2", "flow3"]);
});
it("should return empty array when flow-ids is undefined", async () => {
const mockResponse: FlowResponse = {};
mockApi.makeRequest.mockResolvedValue(mockResponse);
const result = await flowsApi.getFlows();
expect(result).toEqual([]);
});
it("should handle response with flow-ids property correctly", async () => {
// This test ensures we're accessing the hyphenated property name correctly
const mockResponse = {
"flow-ids": ["test-flow"],
"other-property": "should-be-ignored",
};
mockApi.makeRequest.mockResolvedValue(mockResponse);
const result = await flowsApi.getFlows();
expect(result).toEqual(["test-flow"]);
});
});
describe("getFlowBlueprints", () => {
it("should return blueprint-names array from response", async () => {
const mockResponse: FlowResponse = {
"blueprint-names": ["class1", "class2"],
};
mockApi.makeRequest.mockResolvedValue(mockResponse);
const result = await flowsApi.getFlowBlueprints();
expect(mockApi.makeRequest).toHaveBeenCalledWith(
"flow",
{
operation: "list-blueprints",
},
60000,
);
expect(result).toEqual(["class1", "class2"]);
});
it("should handle response with blueprint-names property correctly", async () => {
// This test ensures we're accessing the hyphenated property name correctly
const mockResponse = {
"blueprint-names": ["test-class"],
"other-property": "should-be-ignored",
};
mockApi.makeRequest.mockResolvedValue(mockResponse);
const result = await flowsApi.getFlowBlueprints();
expect(result).toEqual(["test-class"]);
});
});
describe("getFlow", () => {
it("should call makeRequest with correct parameters and parse JSON", async () => {
const flowDefinition = { type: "flow", config: "test" };
const mockResponse: FlowResponse = {
flow: JSON.stringify(flowDefinition), // Must be valid JSON string
description: "Test flow",
};
mockApi.makeRequest.mockResolvedValue(mockResponse);
const result = await flowsApi.getFlow("test-flow-id");
expect(mockApi.makeRequest).toHaveBeenCalledWith(
"flow",
{
operation: "get-flow",
"flow-id": "test-flow-id",
},
60000,
);
expect(result).toEqual(flowDefinition); // Result should be parsed JSON
});
});
describe("getFlowBlueprint", () => {
it("should call makeRequest with correct parameters and parse JSON", async () => {
const blueprintDefinition = { type: "blueprint", name: "test-blueprint" };
const mockResponse: FlowResponse = {
"blueprint-definition": JSON.stringify(blueprintDefinition), // Must be valid JSON string
description: "Test blueprint",
};
mockApi.makeRequest.mockResolvedValue(mockResponse);
const result = await flowsApi.getFlowBlueprint("test-class");
expect(mockApi.makeRequest).toHaveBeenCalledWith(
"flow",
{
operation: "get-blueprint",
"blueprint-name": "test-class",
},
60000,
);
expect(result).toEqual(blueprintDefinition); // Result should be parsed JSON
});
});
});

View file

@ -0,0 +1,370 @@
import { describe, it, expect } from "vitest";
import type {
RequestMessage,
ApiResponse,
TextCompletionRequest,
TextCompletionResponse,
GraphRagRequest,
GraphRagResponse,
AgentRequest,
AgentResponse,
EmbeddingsRequest,
EmbeddingsResponse,
GraphEmbeddingsQueryRequest,
GraphEmbeddingsQueryResponse,
TriplesQueryRequest,
LoadDocumentRequest,
LoadTextRequest,
LibraryRequest,
LibraryResponse,
FlowRequest,
FlowResponse,
DocumentMetadata,
ProcessingMetadata,
} from "../models/messages";
describe("Message Types", () => {
describe("RequestMessage", () => {
it("should have correct structure", () => {
const message: RequestMessage = {
id: "test-id",
service: "test-service",
request: { test: "data" },
};
expect(message.id).toBe("test-id");
expect(message.service).toBe("test-service");
expect(message.request).toEqual({ test: "data" });
});
});
describe("ApiResponse", () => {
it("should have correct structure", () => {
const response: ApiResponse = {
id: "test-id",
response: { result: "success" },
};
expect(response.id).toBe("test-id");
expect(response.response).toEqual({ result: "success" });
});
});
describe("TextCompletionRequest", () => {
it("should have correct structure", () => {
const request: TextCompletionRequest = {
system: "You are a helpful assistant",
prompt: "Hello, world!",
};
expect(request.system).toBe("You are a helpful assistant");
expect(request.prompt).toBe("Hello, world!");
});
});
describe("TextCompletionResponse", () => {
it("should have correct structure", () => {
const response: TextCompletionResponse = {
response: "Hello! How can I help you today?",
};
expect(response.response).toBe("Hello! How can I help you today?");
});
});
describe("GraphRagRequest", () => {
it("should have correct structure with required query", () => {
const request: GraphRagRequest = {
query: "What is the capital of France?",
};
expect(request.query).toBe("What is the capital of France?");
});
it("should have correct structure with optional parameters", () => {
const request: GraphRagRequest = {
query: "What is the capital of France?",
"entity-limit": 100,
"triple-limit": 50,
"max-subgraph-size": 2000,
"max-path-length": 3,
};
expect(request.query).toBe("What is the capital of France?");
expect(request["entity-limit"]).toBe(100);
expect(request["triple-limit"]).toBe(50);
expect(request["max-subgraph-size"]).toBe(2000);
expect(request["max-path-length"]).toBe(3);
});
});
describe("GraphRagResponse", () => {
it("should have correct structure", () => {
const response: GraphRagResponse = {
response: "The capital of France is Paris.",
};
expect(response.response).toBe("The capital of France is Paris.");
});
});
describe("AgentRequest", () => {
it("should have correct structure", () => {
const request: AgentRequest = {
question: "What is the weather like today?",
};
expect(request.question).toBe("What is the weather like today?");
});
});
describe("AgentResponse", () => {
it("should have correct structure with all fields", () => {
const response: AgentResponse = {
thought: "I need to check the weather",
observation: "Weather API shows sunny conditions",
answer: "It is sunny today",
error: undefined,
};
expect(response.thought).toBe("I need to check the weather");
expect(response.observation).toBe("Weather API shows sunny conditions");
expect(response.answer).toBe("It is sunny today");
expect(response.error).toBeUndefined();
});
it("should handle error response", () => {
const response: AgentResponse = {
error: { type: "agent-error", message: "Weather service unavailable" },
};
expect(response.error?.message).toBe("Weather service unavailable");
expect(response.error?.type).toBe("agent-error");
});
});
describe("EmbeddingsRequest", () => {
it("should have correct structure", () => {
const request: EmbeddingsRequest = {
texts: ["This is a test sentence for embedding", "Another text"],
};
expect(request.texts).toEqual(["This is a test sentence for embedding", "Another text"]);
});
});
describe("EmbeddingsResponse", () => {
it("should have correct structure", () => {
// vectors[text_index][dimension_index] - one vector per input text
const response: EmbeddingsResponse = {
vectors: [
[0.1, 0.2, 0.3], // First text's vector
[0.4, 0.5, 0.6], // Second text's vector
],
};
expect(response.vectors).toEqual([
[0.1, 0.2, 0.3],
[0.4, 0.5, 0.6],
]);
});
});
describe("GraphEmbeddingsQueryRequest", () => {
it("should have correct structure", () => {
const request: GraphEmbeddingsQueryRequest = {
vector: [0.1, 0.2, 0.3],
limit: 10,
};
expect(request.vector).toEqual([0.1, 0.2, 0.3]);
expect(request.limit).toBe(10);
});
});
describe("GraphEmbeddingsQueryResponse", () => {
it("should have correct structure", () => {
const response: GraphEmbeddingsQueryResponse = {
entities: [
{ entity: { t: "i", i: "http://example.org/entity1" }, score: 0.95 },
{ entity: { t: "i", i: "http://example.org/entity2" }, score: 0.87 },
],
};
expect(response.entities).toHaveLength(2);
expect(response.entities[0].score).toBe(0.95);
expect(response.entities[0].entity?.t).toBe("i");
expect((response.entities[0].entity as { t: "i"; i: string }).i).toBe("http://example.org/entity1");
expect(response.entities[1].score).toBe(0.87);
});
});
describe("TriplesQueryRequest", () => {
it("should have correct structure with all fields", () => {
const request: TriplesQueryRequest = {
s: { t: "i", i: "http://example.org/subject" },
p: { t: "i", i: "http://example.org/predicate" },
o: { t: "l", v: "object value" },
limit: 100,
};
expect((request.s as { t: "i"; i: string }).i).toBe("http://example.org/subject");
expect((request.p as { t: "i"; i: string }).i).toBe("http://example.org/predicate");
expect((request.o as { t: "l"; v: string }).v).toBe("object value");
expect(request.limit).toBe(100);
});
it("should handle optional fields", () => {
const request: TriplesQueryRequest = {
limit: 50,
};
expect(request.s).toBeUndefined();
expect(request.p).toBeUndefined();
expect(request.o).toBeUndefined();
expect(request.limit).toBe(50);
});
});
describe("LoadDocumentRequest", () => {
it("should have correct structure", () => {
const request: LoadDocumentRequest = {
id: "doc-123",
data: "base64-encoded-document-data",
metadata: [
{
s: { t: "i", i: "http://example.org/doc-123" },
p: { t: "i", i: "http://example.org/title" },
o: { t: "l", v: "Test Document" },
},
],
};
expect(request.id).toBe("doc-123");
expect(request.data).toBe("base64-encoded-document-data");
expect(request.metadata).toHaveLength(1);
});
});
describe("LoadTextRequest", () => {
it("should have correct structure", () => {
const request: LoadTextRequest = {
id: "text-123",
text: "This is some text to load",
charset: "utf-8",
metadata: [],
};
expect(request.id).toBe("text-123");
expect(request.text).toBe("This is some text to load");
expect(request.charset).toBe("utf-8");
expect(request.metadata).toEqual([]);
});
});
describe("DocumentMetadata", () => {
it("should have correct structure", () => {
const metadata: DocumentMetadata = {
id: "doc-123",
time: 1640995200000,
kind: "pdf",
title: "Test Document",
comments: "A test document",
metadata: [],
user: "test-user",
tags: ["test", "document"],
};
expect(metadata.id).toBe("doc-123");
expect(metadata.time).toBe(1640995200000);
expect(metadata.kind).toBe("pdf");
expect(metadata.title).toBe("Test Document");
expect(metadata.comments).toBe("A test document");
expect(metadata.user).toBe("test-user");
expect(metadata.tags).toEqual(["test", "document"]);
});
});
describe("ProcessingMetadata", () => {
it("should have correct structure", () => {
const metadata: ProcessingMetadata = {
id: "proc-123",
"document-id": "doc-123",
time: 1640995200000,
flow: "default-flow",
user: "test-user",
collection: "test-collection",
tags: ["processing", "test"],
};
expect(metadata.id).toBe("proc-123");
expect(metadata["document-id"]).toBe("doc-123");
expect(metadata.time).toBe(1640995200000);
expect(metadata.flow).toBe("default-flow");
expect(metadata.user).toBe("test-user");
expect(metadata.collection).toBe("test-collection");
expect(metadata.tags).toEqual(["processing", "test"]);
});
});
describe("LibraryRequest", () => {
it("should have correct structure", () => {
const request: LibraryRequest = {
operation: "list_documents",
user: "test-user",
collection: "test-collection",
};
expect(request.operation).toBe("list_documents");
expect(request.user).toBe("test-user");
expect(request.collection).toBe("test-collection");
});
});
describe("LibraryResponse", () => {
it("should have correct structure", () => {
const response: LibraryResponse = {
error: new Error(),
"document-metadatas": [
{
id: "doc-1",
title: "Document 1",
time: 1640995200000,
},
],
};
expect(response.error).toBeInstanceOf(Error);
expect(response["document-metadatas"]).toHaveLength(1);
expect(response["document-metadatas"]![0].id).toBe("doc-1");
});
});
describe("FlowRequest", () => {
it("should have correct structure", () => {
const request: FlowRequest = {
operation: "get_flow",
"flow-id": "default-flow",
};
expect(request.operation).toBe("get_flow");
expect(request["flow-id"]).toBe("default-flow");
});
});
describe("FlowResponse", () => {
it("should have correct structure", () => {
const response: FlowResponse = {
"flow-ids": ["flow-1", "flow-2"],
flow: "flow-definition",
description: "A test flow",
error: undefined,
};
expect(response["flow-ids"]).toEqual(["flow-1", "flow-2"]);
expect(response.flow).toBe("flow-definition");
expect(response.description).toBe("A test flow");
expect(response.error).toBeUndefined();
});
});
});

View file

@ -0,0 +1,285 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { ServiceCallMulti } from "../socket/service-call-multi";
// Mock WebSocket constants
vi.stubGlobal("WebSocket", {
OPEN: 1,
CONNECTING: 0,
CLOSING: 2,
CLOSED: 3,
});
// Mock Socket interface
const mockSocket = {
inflight: {} as Record<string, unknown>,
ws: {
send: vi.fn(),
readyState: 1, // WebSocket.OPEN
},
reopen: vi.fn(),
};
// Mock setTimeout and clearTimeout
const mockSetTimeout = vi.fn();
const mockClearTimeout = vi.fn();
vi.stubGlobal("setTimeout", mockSetTimeout);
vi.stubGlobal("clearTimeout", mockClearTimeout);
describe("ServiceCallMulti", () => {
let mockSuccess: ReturnType<typeof vi.fn>;
let mockError: ReturnType<typeof vi.fn>;
let mockReceiver: ReturnType<typeof vi.fn>;
let serviceCallMulti: ServiceCallMulti;
beforeEach(() => {
vi.clearAllMocks();
mockSuccess = vi.fn();
mockError = vi.fn();
mockReceiver = vi.fn();
mockSocket.inflight = {} as Record<string, unknown>;
mockSocket.ws = {
send: vi.fn(),
readyState: 1, // WebSocket.OPEN
};
mockSocket.reopen.mockClear();
serviceCallMulti = new ServiceCallMulti(
"test-mid",
{ id: "test-id", service: "test-service", request: { test: "data" } },
mockSuccess,
mockError,
5000, // 5 second timeout
3, // 3 retries
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockSocket as any,
mockReceiver,
);
});
it("should initialize with correct properties", () => {
expect(serviceCallMulti.mid).toBe("test-mid");
expect(serviceCallMulti.timeout).toBe(5000);
expect(serviceCallMulti.retries).toBe(3);
expect(serviceCallMulti.complete).toBe(false);
expect(serviceCallMulti.socket).toBe(mockSocket);
expect(serviceCallMulti.receiver).toBe(mockReceiver);
});
it("should register itself in socket inflight when started", () => {
serviceCallMulti.start();
expect(mockSocket.inflight["test-mid"]).toBe(serviceCallMulti);
});
it("should send message on successful attempt", () => {
serviceCallMulti.start();
expect(mockSocket.ws.send).toHaveBeenCalledWith(
JSON.stringify({
id: "test-id",
service: "test-service",
request: { test: "data" },
}),
);
expect(mockSetTimeout).toHaveBeenCalled();
});
it("should handle response when receiver returns true (completion)", () => {
mockReceiver.mockReturnValue(true); // Signal completion
const response = { result: "success" };
serviceCallMulti.start();
serviceCallMulti.onReceived(response);
expect(mockReceiver).toHaveBeenCalledWith(response);
expect(serviceCallMulti.complete).toBe(true);
expect(mockSuccess).toHaveBeenCalledWith(response);
expect(mockClearTimeout).toHaveBeenCalled();
expect(mockSocket.inflight["test-mid"]).toBeUndefined();
});
it("should handle response when receiver returns false (continue)", () => {
mockReceiver.mockReturnValue(false); // Signal to continue
const response = { partial: "data" };
serviceCallMulti.start();
serviceCallMulti.onReceived(response);
expect(mockReceiver).toHaveBeenCalledWith(response);
expect(serviceCallMulti.complete).toBe(false);
expect(mockSuccess).not.toHaveBeenCalled();
expect(mockClearTimeout).not.toHaveBeenCalled();
expect(mockSocket.inflight["test-mid"]).toBe(serviceCallMulti);
});
it("should handle timeout and retry", () => {
serviceCallMulti.start();
// Initial retries should be 3, but start() calls attempt() which decrements to 2
expect(serviceCallMulti.retries).toBe(2);
// Simulate timeout
serviceCallMulti.onTimeout();
expect(mockClearTimeout).toHaveBeenCalled();
expect(serviceCallMulti.retries).toBe(1); // Should decrement from 2 to 1
});
it("should exhaust retries and call error callback", () => {
// Set retries to 0 to force immediate failure
serviceCallMulti.retries = 0;
serviceCallMulti.start();
expect(mockError).toHaveBeenCalledWith("Ran out of retries");
expect(mockSocket.inflight["test-mid"]).toBeUndefined();
});
it("should handle WebSocket send failure", () => {
mockSocket.ws.send.mockImplementation(() => {
throw new Error("Connection failed");
});
serviceCallMulti.start();
expect(mockSocket.reopen).toHaveBeenCalled();
// With exponential backoff, the delay should be calculated as:
// SOCKET_RECONNECTION_TIMEOUT * Math.pow(2, 3 - retries) + random
// Since retries is decremented to 2 after start(), it's 3 - 2 = 1
// So base delay is 2000 * 2^1 = 4000, plus random up to 1000
// The delay should be between 4000 and 5000ms (capped at 30000)
const callArgs = mockSetTimeout.mock.calls[0];
expect(callArgs[0]).toEqual(expect.any(Function));
expect(callArgs[1]).toBeGreaterThanOrEqual(4000);
expect(callArgs[1]).toBeLessThanOrEqual(5000);
});
it("should handle missing WebSocket connection", () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(mockSocket as any).ws = null;
serviceCallMulti.start();
// Should trigger reopen and schedule with exponential backoff
expect(mockSocket.reopen).toHaveBeenCalled();
// Same calculation as above - base delay 4000ms + random up to 1000ms
const callArgs = mockSetTimeout.mock.calls[0];
expect(callArgs[0]).toEqual(expect.any(Function));
expect(callArgs[1]).toBeGreaterThanOrEqual(4000);
expect(callArgs[1]).toBeLessThanOrEqual(5000);
});
it("should not process response if already complete", () => {
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
serviceCallMulti.complete = true;
serviceCallMulti.onReceived({ result: "test" });
expect(consoleSpy).toHaveBeenCalledWith(
"test-mid",
"should not happen, request is already complete",
);
consoleSpy.mockRestore();
});
it("should not timeout if already complete", () => {
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
serviceCallMulti.complete = true;
serviceCallMulti.onTimeout();
expect(consoleSpy).toHaveBeenCalledWith(
"test-mid",
"timeout should not happen, request is already complete",
);
consoleSpy.mockRestore();
});
it("should not attempt if already complete", () => {
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
serviceCallMulti.complete = true;
serviceCallMulti.attempt();
expect(consoleSpy).toHaveBeenCalledWith(
"test-mid",
"attempt should not be called, request is already complete",
);
consoleSpy.mockRestore();
});
it("should handle streaming responses correctly", () => {
mockReceiver
.mockReturnValueOnce(false) // First response - continue
.mockReturnValueOnce(false) // Second response - continue
.mockReturnValueOnce(true); // Third response - complete
serviceCallMulti.start();
// First response
serviceCallMulti.onReceived({ chunk: 1 });
expect(serviceCallMulti.complete).toBe(false);
expect(mockSuccess).not.toHaveBeenCalled();
// Second response
serviceCallMulti.onReceived({ chunk: 2 });
expect(serviceCallMulti.complete).toBe(false);
expect(mockSuccess).not.toHaveBeenCalled();
// Third response (final)
serviceCallMulti.onReceived({ chunk: 3, final: true });
expect(serviceCallMulti.complete).toBe(true);
expect(mockSuccess).toHaveBeenCalledWith({ chunk: 3, final: true });
});
it("should handle receiver function errors gracefully", () => {
mockReceiver.mockImplementation(() => {
throw new Error("Receiver error");
});
serviceCallMulti.start();
expect(() => {
serviceCallMulti.onReceived({ test: "data" });
}).toThrow("Receiver error");
});
it("should handle multiple timeout scenarios", () => {
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
serviceCallMulti.start();
// After start, retries should be 2 (decremented from 3)
expect(serviceCallMulti.retries).toBe(2);
// First timeout
serviceCallMulti.onTimeout();
expect(serviceCallMulti.retries).toBe(1);
// Second timeout
serviceCallMulti.onTimeout();
expect(serviceCallMulti.retries).toBe(0);
consoleSpy.mockRestore();
});
it("should clean up properly when receiver signals completion", () => {
mockReceiver.mockReturnValue(true);
serviceCallMulti.start();
const response = { final: true };
serviceCallMulti.onReceived(response);
expect(serviceCallMulti.complete).toBe(true);
expect(mockClearTimeout).toHaveBeenCalled();
expect(mockSocket.inflight["test-mid"]).toBeUndefined();
expect(mockSuccess).toHaveBeenCalledWith(response);
});
});

View file

@ -0,0 +1,239 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { ServiceCall } from "../socket/service-call";
// Mock WebSocket constants
vi.stubGlobal("WebSocket", {
OPEN: 1,
CONNECTING: 0,
CLOSING: 2,
CLOSED: 3,
});
// Mock Socket interface
const mockSocket = {
inflight: {} as Record<string, unknown>,
ws: {
send: vi.fn(),
readyState: 1, // WebSocket.OPEN
},
reopen: vi.fn(),
};
// Mock setTimeout and clearTimeout
const mockSetTimeout = vi.fn();
const mockClearTimeout = vi.fn();
vi.stubGlobal("setTimeout", mockSetTimeout);
vi.stubGlobal("clearTimeout", mockClearTimeout);
describe("ServiceCall", () => {
let mockSuccess: ReturnType<typeof vi.fn>;
let mockError: ReturnType<typeof vi.fn>;
let serviceCall: ServiceCall;
beforeEach(() => {
vi.clearAllMocks();
mockSuccess = vi.fn();
mockError = vi.fn();
mockSocket.inflight = {} as Record<string, unknown>;
mockSocket.ws = {
send: vi.fn(),
readyState: 1, // WebSocket.OPEN
};
mockSocket.reopen.mockClear();
serviceCall = new ServiceCall(
"test-mid",
{ id: "test-id", service: "test-service", request: { test: "data" } },
mockSuccess,
mockError,
5000, // 5 second timeout
3, // 3 retries
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockSocket as any,
);
});
it("should initialize with correct properties", () => {
expect(serviceCall.mid).toBe("test-mid");
expect(serviceCall.timeout).toBe(5000);
expect(serviceCall.retries).toBe(3);
expect(serviceCall.complete).toBe(false);
expect(serviceCall.socket).toBe(mockSocket);
});
it("should register itself in socket inflight when started", () => {
serviceCall.start();
expect(mockSocket.inflight["test-mid"]).toBe(serviceCall);
});
it("should send message on successful attempt", () => {
serviceCall.start();
expect(mockSocket.ws.send).toHaveBeenCalledWith(
JSON.stringify({
id: "test-id",
service: "test-service",
request: { test: "data" },
}),
);
expect(mockSetTimeout).toHaveBeenCalled();
});
it("should handle successful response", () => {
const responseData = { result: "success" };
const message = { response: responseData };
serviceCall.start();
serviceCall.onReceived(message);
expect(serviceCall.complete).toBe(true);
expect(mockSuccess).toHaveBeenCalledWith(responseData);
expect(mockClearTimeout).toHaveBeenCalled();
expect(mockSocket.inflight["test-mid"]).toBeUndefined();
});
it("should handle timeout and retry", () => {
serviceCall.start();
// Initial retries should be 3, but start() calls attempt() which decrements to 2
expect(serviceCall.retries).toBe(2);
// Simulate timeout
serviceCall.onTimeout();
expect(mockClearTimeout).toHaveBeenCalled();
expect(serviceCall.retries).toBe(1); // Should decrement from 2 to 1
});
it("should exhaust retries and call error callback", () => {
// Set retries to 0 to force immediate failure
serviceCall.retries = 0;
serviceCall.start();
expect(mockError).toHaveBeenCalledWith("Ran out of retries");
expect(mockSocket.inflight["test-mid"]).toBeUndefined();
});
it("should handle WebSocket send failure", () => {
mockSocket.ws.send.mockImplementation(() => {
throw new Error("Connection failed");
});
serviceCall.start();
// Should NOT call reopen anymore - BaseApi handles reconnection
expect(mockSocket.reopen).not.toHaveBeenCalled();
// With exponential backoff, the delay should be calculated as:
// SOCKET_RECONNECTION_TIMEOUT * Math.pow(2, 3 - retries) + random
// Since retries is decremented to 2 after start(), it's 3 - 2 = 1
// So base delay is 2000 * 2^1 = 4000, plus random up to 1000
// The delay should be between 4000 and 5000ms (capped at 30000)
const callArgs = mockSetTimeout.mock.calls[0];
expect(callArgs[0]).toEqual(expect.any(Function));
expect(callArgs[1]).toBeGreaterThanOrEqual(4000);
expect(callArgs[1]).toBeLessThanOrEqual(5000);
});
it("should handle missing WebSocket connection", () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(mockSocket as any).ws = null;
serviceCall.start();
// Should NOT trigger reopen - just wait for BaseApi to reconnect
expect(mockSocket.reopen).not.toHaveBeenCalled();
// Same calculation as above - base delay 4000ms + random up to 1000ms
const callArgs = mockSetTimeout.mock.calls[0];
expect(callArgs[0]).toEqual(expect.any(Function));
expect(callArgs[1]).toBeGreaterThanOrEqual(4000);
expect(callArgs[1]).toBeLessThanOrEqual(5000);
});
it("should not process response if already complete", () => {
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
serviceCall.complete = true;
serviceCall.onReceived({ result: "test" });
expect(consoleSpy).toHaveBeenCalledWith(
"test-mid",
"should not happen, request is already complete",
);
consoleSpy.mockRestore();
});
it("should not timeout if already complete", () => {
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
serviceCall.complete = true;
serviceCall.onTimeout();
expect(consoleSpy).toHaveBeenCalledWith(
"test-mid",
"timeout should not happen, request is already complete",
);
consoleSpy.mockRestore();
});
it("should not attempt if already complete", () => {
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
serviceCall.complete = true;
serviceCall.attempt();
expect(consoleSpy).toHaveBeenCalledWith(
"test-mid",
"attempt should not be called, request is already complete",
);
consoleSpy.mockRestore();
});
it("should handle multiple retries correctly", () => {
mockSocket.ws.send.mockImplementation(() => {
throw new Error("Connection failed");
});
serviceCall.start();
// Should have decremented retries and scheduled a retry
expect(serviceCall.retries).toBe(2);
// Should NOT call reopen - BaseApi handles reconnection
expect(mockSocket.reopen).not.toHaveBeenCalled();
});
it("should clean up properly on successful response", () => {
serviceCall.start();
const responseData = { success: true };
const message = { response: responseData };
serviceCall.onReceived(message);
expect(serviceCall.complete).toBe(true);
expect(mockClearTimeout).toHaveBeenCalled();
expect(mockSocket.inflight["test-mid"]).toBeUndefined();
expect(mockSuccess).toHaveBeenCalledWith(responseData);
});
it("should handle edge case of negative retries", () => {
serviceCall.retries = -1;
serviceCall.attempt();
expect(mockError).toHaveBeenCalledWith("Ran out of retries");
});
it("should bind timeout callbacks correctly", () => {
serviceCall.start();
// Verify that setTimeout was called with a bound function
expect(mockSetTimeout).toHaveBeenCalledWith(expect.any(Function), 5000);
});
});

View file

@ -0,0 +1,10 @@
// @trustgraph/client
// TrustGraph TypeScript Client
// Export models (data types)
export * from "./models/Triple";
export * from "./models/messages";
export * from "./models/namespaces";
// Export socket client
export * from "./socket/trustgraph-socket";

View file

@ -0,0 +1,40 @@
// Term type discriminators matching the wire format
// i = IRI, b = BLANK node, l = LITERAL, t = TRIPLE (reified)
export type TermType = "i" | "b" | "l" | "t";
export interface IriTerm {
t: "i";
i: string;
}
export interface BlankTerm {
t: "b";
d: string;
}
export interface LiteralTerm {
t: "l";
v: string;
dt?: string; // datatype
ln?: string; // language
}
export interface TripleTerm {
t: "t";
tr?: Triple;
}
export type Term = IriTerm | BlankTerm | LiteralTerm | TripleTerm;
export interface PartialTriple {
s?: Term;
p?: Term;
o?: Term;
}
export interface Triple {
s: Term;
p: Term;
o: Term;
g?: string; // graph (renamed from direc to match backend)
}

View file

@ -0,0 +1,496 @@
import { Triple, Term } from "./Triple";
// FIXME: Better types?
export type Request = object;
export type Response = object;
export type Error = object | string;
export interface ResponseError {
type?: string;
message: string;
}
export interface RequestMessage {
id: string;
service: string;
request: Request;
flow?: string;
}
export interface ApiResponse {
id: string;
response: Response;
}
export interface Metadata {
id?: string;
metadata?: Triple[];
user?: string;
collection?: string;
}
export interface EntityEmbeddings {
entity?: Term;
vectors?: number[][];
}
export interface GraphEmbeddings {
metadata?: Metadata;
entities?: EntityEmbeddings[];
}
export interface TextCompletionRequest {
system: string;
prompt: string;
streaming?: boolean;
}
export interface TextCompletionResponse {
response: string;
// Streaming fields
end_of_stream?: boolean;
error?: {
message: string;
type?: string;
};
// Token usage (appears in final message)
in_token?: number;
out_token?: number;
model?: string;
}
export interface GraphRagRequest {
query: string;
user?: string;
collection?: string;
"entity-limit"?: number; // Default: 50
"triple-limit"?: number; // Default: 30
"max-subgraph-size"?: number; // Default: 1000
"max-path-length"?: number; // Default: 2
streaming?: boolean;
}
export interface GraphRagResponse {
response: string;
// Streaming fields
chunk?: string;
end_of_stream?: boolean;
error?: {
message: string;
type?: string;
};
// Token usage (appears in final message)
in_token?: number;
out_token?: number;
model?: string;
// Explainability fields
message_type?: "chunk" | "explain";
explain_id?: string;
explain_graph?: string; // Named graph where explain data is stored (e.g., urn:graph:retrieval)
end_of_session?: boolean;
}
export interface DocumentRagRequest {
query: string;
user?: string;
collection?: string;
"doc-limit"?: number; // Default: 20
streaming?: boolean;
}
export interface DocumentRagResponse {
response: string;
// Streaming fields
chunk?: string;
end_of_stream?: boolean;
error?: {
message: string;
type?: string;
};
// Token usage (appears in final message)
in_token?: number;
out_token?: number;
model?: string;
// Explainability fields
message_type?: "chunk" | "explain";
explain_id?: string;
explain_graph?: string;
end_of_session?: boolean;
}
export interface AgentRequest {
question: string;
user?: string;
streaming?: boolean;
}
export interface AgentResponse {
// Streaming response format (new protocol)
chunk_type?: "thought" | "action" | "observation" | "answer" | "final-answer" | "explain" | "error";
content?: string;
end_of_message?: boolean;
end_of_dialog?: boolean;
// Legacy fields for backward compatibility with non-streaming
thought?: string;
observation?: string;
answer?: string;
error?: ResponseError;
// Token usage (appears in final message)
in_token?: number;
out_token?: number;
model?: string;
// Explainability fields
message_type?: "chunk" | "explain";
explain_id?: string;
explain_graph?: string;
}
export interface EmbeddingsRequest {
texts: string[];
}
export interface EmbeddingsResponse {
vectors: number[][]; // One vector per input text
}
export interface GraphEmbeddingsQueryRequest {
vector: number[]; // Single query vector
limit: number;
user?: string;
collection?: string;
}
export interface EntityMatch {
entity: Term | null;
score: number;
}
export interface GraphEmbeddingsQueryResponse {
entities: EntityMatch[];
}
export interface TriplesQueryRequest {
s?: Term;
p?: Term;
o?: Term;
g?: string; // Named graph URI filter (plain string, not Term)
limit: number;
user?: string;
collection?: string;
}
export interface TriplesQueryResponse {
response: Triple[];
}
export interface RowsQueryRequest {
query: string;
user?: string;
collection?: string;
variables?: Record<string, unknown>;
operation_name?: string;
}
export interface RowsQueryResponse {
data?: Record<string, unknown>;
errors?: Record<string, unknown>[];
extensions?: Record<string, unknown>;
values?: unknown[];
}
export interface NlpQueryRequest {
question: string;
max_results?: number;
}
export interface NlpQueryResponse {
graphql_query?: string;
variables?: Record<string, unknown>;
detected_schemas?: Record<string, unknown>[];
confidence?: number;
}
export interface StructuredQueryRequest {
question: string;
user?: string;
collection?: string;
}
export interface StructuredQueryResponse {
data?: Record<string, unknown>;
errors?: Record<string, unknown>[];
}
export interface RowEmbeddingsQueryRequest {
vector: number[]; // Single query vector
schema_name: string;
user?: string;
collection?: string;
index_name?: string;
limit?: number;
}
export interface RowEmbeddingsMatch {
index_name: string;
index_value: string[];
text: string;
score: number;
}
export interface RowEmbeddingsQueryResponse {
matches?: RowEmbeddingsMatch[];
error?: {
message: string;
type?: string;
};
}
export interface LoadDocumentRequest {
id?: string;
data: string;
metadata?: Triple[];
}
export type LoadDocumentResponse = void;
export interface LoadTextRequest {
id?: string;
text: string;
charset?: string;
metadata?: Triple[];
}
export type LoadTextResponse = void;
export interface DocumentMetadata {
id?: string;
time?: number;
kind?: string;
title?: string;
comments?: string;
metadata?: Triple[];
user?: string;
tags?: string[];
"document-type"?: string;
}
export interface ProcessingMetadata {
id?: string;
"document-id"?: string;
time?: number;
flow?: string;
user?: string;
collection?: string;
tags?: string[];
}
export interface LibraryRequest {
operation: string;
"document-id"?: string;
"processing-id"?: string;
"document-metadata"?: DocumentMetadata;
"processing-metadata"?: ProcessingMetadata;
content?: string;
user?: string;
collection?: string;
metadata?: Triple[];
id?: string;
flow?: string;
}
export interface LibraryResponse {
error: Error;
"document-metadata"?: DocumentMetadata;
content?: string;
"document-metadatas"?: DocumentMetadata[];
"processing-metadata"?: ProcessingMetadata;
}
export interface KnowledgeRequest {
operation: string;
user?: string;
id?: string;
flow?: string;
collection?: string;
triples?: Triple[];
"graph-embeddings"?: GraphEmbeddings;
}
export interface KnowledgeResponse {
error?: Error;
ids?: string[];
eos?: boolean;
triples?: Triple[];
"graph-embeddings"?: GraphEmbeddings;
}
export interface FlowRequest {
operation: string;
"blueprint-name"?: string;
"blueprint-definition"?: string;
description?: string;
"flow-id"?: string;
parameters?: Record<string, unknown>;
user?: string;
}
export interface FlowResponse {
"blueprint-names"?: string[];
"flow-ids"?: string[];
ids?: string[];
flow?: string;
"blueprint-definition"?: string;
description?: string;
error?:
| {
message?: string;
}
| Error;
}
export interface PromptRequest {
id: string;
terms: Record<string, unknown>;
streaming?: boolean;
}
export interface PromptResponse {
text: string;
// Streaming fields
end_of_stream?: boolean;
error?: {
message: string;
type?: string;
};
// Token usage (appears in final message)
in_token?: number;
out_token?: number;
model?: string;
}
export type ConfigRequest = object;
export type ConfigResponse = object;
// Chunked Upload Types
export interface ChunkedUploadDocumentMetadata {
id: string;
time: number;
kind: string;
title: string;
comments?: string;
metadata?: Triple[];
user: string;
collection?: string;
tags?: string[];
}
export interface BeginUploadRequest {
operation: "begin-upload";
"document-metadata": ChunkedUploadDocumentMetadata;
"total-size": number;
"chunk-size"?: number;
}
export interface BeginUploadResponse {
"upload-id": string;
"chunk-size": number;
"total-chunks": number;
error?: ResponseError;
}
export interface UploadChunkRequest {
operation: "upload-chunk";
"upload-id": string;
"chunk-index": number;
content: string; // base64-encoded
user: string;
}
export interface UploadChunkResponse {
"upload-id": string;
"chunk-index": number;
"chunks-received": number;
"total-chunks": number;
"bytes-received": number;
"total-bytes": number;
error?: ResponseError;
}
export interface CompleteUploadRequest {
operation: "complete-upload";
"upload-id": string;
user: string;
}
export interface CompleteUploadResponse {
"document-id": string;
"object-id": string;
error?: ResponseError;
}
export interface GetUploadStatusRequest {
operation: "get-upload-status";
"upload-id": string;
user: string;
}
export interface GetUploadStatusResponse {
"upload-id": string;
"upload-state": "in-progress" | "completed" | "expired";
"chunks-received": number;
"total-chunks": number;
"received-chunks": number[];
"missing-chunks": number[];
"bytes-received": number;
"total-bytes": number;
error?: ResponseError;
}
export interface AbortUploadRequest {
operation: "abort-upload";
"upload-id": string;
user: string;
}
export interface AbortUploadResponse {
error?: ResponseError;
}
export interface ListUploadsRequest {
operation: "list-uploads";
user: string;
}
export interface UploadSession {
"upload-id": string;
"document-id": string;
"document-metadata-json": string;
"total-size": number;
"chunk-size": number;
"total-chunks": number;
"chunks-received": number;
"created-at": string;
}
export interface ListUploadsResponse {
"upload-sessions": UploadSession[];
error?: ResponseError;
}
export interface StreamDocumentRequest {
operation: "stream-document";
"document-id": string;
"chunk-size"?: number;
user: string;
}
export interface StreamDocumentResponse {
content: string; // base64-encoded chunk
"chunk-index": number;
"total-chunks": number;
error?: ResponseError;
}

View file

@ -0,0 +1,42 @@
/**
* RDF namespace constants for TrustGraph
* Used for querying explainability data, provenance chains, and knowledge graph
*/
// TrustGraph namespace
export const TG = "https://trustgraph.ai/ns/";
export const TG_QUERY = TG + "query";
export const TG_EDGE_COUNT = TG + "edgeCount";
export const TG_SELECTED_EDGE = TG + "selectedEdge";
export const TG_EDGE = TG + "edge";
export const TG_REASONING = TG + "reasoning";
export const TG_CONTENT = TG + "content";
export const TG_REIFIES = TG + "reifies";
export const TG_DOCUMENT = TG + "document";
// W3C PROV-O namespace
export const PROV = "http://www.w3.org/ns/prov#";
export const PROV_STARTED_AT_TIME = PROV + "startedAtTime";
export const PROV_WAS_DERIVED_FROM = PROV + "wasDerivedFrom";
export const PROV_WAS_GENERATED_BY = PROV + "wasGeneratedBy";
export const PROV_ACTIVITY = PROV + "Activity";
export const PROV_ENTITY = PROV + "Entity";
// RDFS namespace
export const RDFS = "http://www.w3.org/2000/01/rdf-schema#";
export const RDFS_LABEL = RDFS + "label";
// RDF namespace
export const RDF = "http://www.w3.org/1999/02/22-rdf-syntax-ns#";
export const RDF_TYPE = RDF + "type";
// Schema.org namespace (used in document metadata)
export const SCHEMA = "https://schema.org/";
export const SCHEMA_NAME = SCHEMA + "name";
export const SCHEMA_DESCRIPTION = SCHEMA + "description";
export const SCHEMA_AUTHOR = SCHEMA + "author";
export const SCHEMA_KEYWORDS = SCHEMA + "keywords";
// SKOS namespace
export const SKOS = "http://www.w3.org/2004/02/skos/core#";
export const SKOS_DEFINITION = SKOS + "definition";

View file

@ -0,0 +1,171 @@
import { RequestMessage } from "../models/messages";
// Constant defining the delay before attempting to reconnect a WebSocket
// (2 seconds)
export const SOCKET_RECONNECTION_TIMEOUT = 2000;
// Forward declare Socket type to avoid circular dependency
// Using a minimal interface that matches what BaseApi provides
interface Socket {
ws?: WebSocket;
inflight: { [key: string]: ServiceCallMulti };
reopen: () => void;
getNextId?: () => string;
user?: string;
}
export class ServiceCallMulti {
constructor(
mid: string,
msg: RequestMessage,
success: (resp: unknown) => void,
error: (err: object | string) => void,
timeout: number,
retries: number,
socket: Socket,
receiver: (resp: unknown) => boolean,
) {
this.mid = mid;
this.msg = msg;
this.success = success;
this.error = error;
this.timeout = timeout;
this.retries = retries;
this.socket = socket;
this.complete = false;
this.receiver = receiver;
}
mid: string;
msg: RequestMessage;
success: (resp: unknown) => void;
error: (err: object | string) => void;
receiver: (resp: unknown) => boolean;
timeoutId?: ReturnType<typeof setTimeout>;
timeout: number;
retries: number;
socket: Socket;
complete: boolean;
start() {
this.socket.inflight[this.mid] = this;
this.attempt();
}
onReceived(resp: object) {
if (this.complete == true)
console.log(this.mid, "should not happen, request is already complete");
const fin = this.receiver(resp);
if (fin) {
this.complete = true;
// console.log("Received for", this.mid);
clearTimeout(this.timeoutId);
this.timeoutId = undefined;
delete this.socket.inflight[this.mid];
this.success(resp);
}
}
/**
* Called when socket connects - immediately retry if we were waiting
*/
retryNow() {
if (this.complete) return;
// Clear any pending backoff timer
clearTimeout(this.timeoutId);
this.timeoutId = undefined;
// Restore retry count since we didn't actually fail
this.retries++;
// Attempt immediately
this.attempt();
}
onTimeout() {
if (this.complete == true)
console.log(
this.mid,
"timeout should not happen, request is already complete",
);
console.log("Request", this.mid, "timed out");
clearTimeout(this.timeoutId);
this.attempt();
}
attempt() {
// console.log("attempt:", this.mid);
if (this.complete == true)
console.log(
this.mid,
"attempt should not be called, request is already complete",
);
this.retries--;
if (this.retries < 0) {
console.log("Request", this.mid, "ran out of retries");
clearTimeout(this.timeoutId);
delete this.socket.inflight[this.mid];
this.error("Ran out of retries");
return; // Exit early - no more attempts
}
// Check if WebSocket connection is available and ready
if (this.socket.ws && this.socket.ws.readyState === WebSocket.OPEN) {
try {
this.socket.ws.send(JSON.stringify(this.msg));
this.timeoutId = setTimeout(this.onTimeout.bind(this), this.timeout);
return;
} catch (e) {
console.log("Error:", e);
console.log("Message send failure, retry...");
// Calculate backoff delay with jitter
const backoffDelay = Math.min(
SOCKET_RECONNECTION_TIMEOUT * Math.pow(2, 3 - this.retries) +
Math.random() * 1000,
30000, // Max 30 seconds
);
this.timeoutId = setTimeout(this.attempt.bind(this), backoffDelay);
console.log("Reopen...");
// Attempt to reopen the WebSocket connection
this.socket.reopen();
}
} else {
// No WebSocket connection available or not ready
// Check if socket is connecting
if (
this.socket.ws &&
this.socket.ws.readyState === WebSocket.CONNECTING
) {
// Wait a bit longer for connection to establish
setTimeout(this.attempt.bind(this), 500);
} else {
// Socket is closed or closing, trigger reopen
console.log("Socket not ready, reopening...");
this.socket.reopen();
// Calculate backoff delay
const backoffDelay = Math.min(
SOCKET_RECONNECTION_TIMEOUT * Math.pow(2, 3 - this.retries) +
Math.random() * 1000,
30000,
);
setTimeout(this.attempt.bind(this), backoffDelay);
}
}
}
}

View file

@ -0,0 +1,239 @@
import { RequestMessage } from "../models/messages";
// Constant defining the delay before attempting to reconnect a WebSocket
// (2 seconds)
export const SOCKET_RECONNECTION_TIMEOUT = 2000;
// Forward declare Socket type to avoid circular dependency
// Using a minimal interface that matches what BaseApi provides
interface Socket {
ws?: WebSocket;
inflight: { [key: string]: ServiceCall };
reopen: () => void;
getNextId?: () => string;
user?: string;
}
/**
* ServiceCall represents a single request/response cycle over a WebSocket
* connection with built-in retry logic, timeout handling, and completion
* tracking.
*
* This class manages the lifecycle of a service call including:
* - Sending the initial request
* - Handling timeouts and retries
* - Managing completion state
* - Cleaning up resources
*/
export class ServiceCall {
constructor(
mid: string, // Message ID - unique identifier for this request
msg: RequestMessage, // The actual message/request to send
success: (resp: unknown) => void, // Callback function called on
// successful response
error: (err: object | string) => void, // Callback function called on error/failure
timeout: number, // Timeout duration in milliseconds
retries: number, // Number of retry attempts allowed
socket: Socket, // WebSocket instance to send the message through
) {
this.mid = mid;
this.msg = msg;
this.success = success;
this.error = error;
this.timeout = timeout;
this.retries = retries;
this.socket = socket;
this.complete = false; // Track if this request has completed
}
// Properties
mid: string; // Message identifier
msg: RequestMessage; // The request message
success: (resp: unknown) => void; // Success callback
error: (err: object | string) => void; // Error callback
timeoutId?: ReturnType<typeof setTimeout>; // Reference to the active timeout timer
timeout: number; // Timeout duration in milliseconds
retries: number; // Remaining retry attempts
socket: Socket; // WebSocket connection reference
complete: boolean; // Flag indicating if request is complete
/**
* Initiates the service call by registering it with the socket's inflight
* requests and making the first attempt to send the message
*/
start() {
// Register this request as "in-flight" so responses can be matched to it
this.socket.inflight[this.mid] = this;
// Make the first attempt to send the message
this.attempt();
}
/**
* Called when a response is received for this request
* Handles cleanup and calls the success or error callback based on response
*
* @param resp - The response object received from the server
*/
onReceived(resp: object) {
// Defensive check - this shouldn't happen but log if it does
if (this.complete == true)
console.log(this.mid, "should not happen, request is already complete");
// Mark as complete to prevent duplicate processing
this.complete = true;
// Clean up timeout timer
clearTimeout(this.timeoutId);
this.timeoutId = undefined;
// Remove from inflight requests tracker
delete this.socket.inflight[this.mid];
// Check if the response contains an error (error can be directly in resp or nested under response)
let errorToHandle: unknown = null;
// Check for direct error in response
if (resp && typeof resp === "object" && "error" in resp) {
errorToHandle = (resp as Record<string, unknown>).error;
}
// Check for nested error under response property
else if (resp && typeof resp === "object" && "response" in resp) {
const response = (resp as Record<string, unknown>).response;
if (response && typeof response === "object" && "error" in response) {
errorToHandle = (response as Record<string, unknown>).error;
}
}
if (errorToHandle) {
// Response contains an error - call error callback
const errorObj = errorToHandle as Record<string, unknown>;
const errorMessage =
(typeof errorObj.message === "string" ? errorObj.message : null) ||
(typeof errorObj.type === "string" ? errorObj.type : null) ||
"Unknown error";
console.log(
"ServiceCall: API error detected in response:",
errorMessage,
"Full error:",
errorToHandle,
);
this.error(new Error(errorMessage));
return;
}
// Extract the response field from the message object
// The resp parameter is the full message: {id, response, complete}
// We need to pass just the response field to the success callback
const responseData = (resp as { response?: unknown }).response;
this.success(responseData);
}
/**
* Called when socket connects - immediately retry if we were waiting
*/
retryNow() {
if (this.complete) return;
// Clear any pending backoff timer
clearTimeout(this.timeoutId);
this.timeoutId = undefined;
// Restore retry count since we didn't actually fail
this.retries++;
// Attempt immediately
this.attempt();
}
/**
* Called when the request times out
* Triggers another attempt if retries are available
*/
onTimeout() {
// Defensive check - this shouldn't happen but log if it does
if (this.complete == true)
console.log(
this.mid,
"timeout should not happen, request is already complete",
);
console.log("Request", this.mid, "timed out");
// Clear the current timeout
clearTimeout(this.timeoutId);
// Try again (this will check retry count)
this.attempt();
}
/**
* Calculates exponential backoff delay with jitter
* @returns backoff delay in milliseconds
*/
calculateBackoff() {
return Math.min(
SOCKET_RECONNECTION_TIMEOUT * Math.pow(2, 3 - this.retries) +
Math.random() * 1000,
30000, // Max 30 seconds
);
}
/**
* Core retry logic - attempts to send the message over the WebSocket
* Handles retries and waits for BaseApi to handle reconnection
*/
attempt() {
// Defensive check - this shouldn't be called on completed requests
if (this.complete == true)
console.log(
this.mid,
"attempt should not be called, request is already complete",
);
// Decrement retry counter
this.retries--;
// Check if we've exhausted all retries
if (this.retries < 0) {
console.log("Request", this.mid, "ran out of retries");
// Clean up and call error callback
clearTimeout(this.timeoutId);
delete this.socket.inflight[this.mid];
this.error("Ran out of retries");
return; // Exit early - no more attempts
}
// Check if WebSocket connection is available and ready
if (this.socket.ws && this.socket.ws.readyState === WebSocket.OPEN) {
try {
// Attempt to send the message as JSON
this.socket.ws.send(JSON.stringify(this.msg));
// Set up timeout for this attempt
this.timeoutId = setTimeout(this.onTimeout.bind(this), this.timeout);
return; // Success - message sent, waiting for response or timeout
} catch (e) {
// Handle send failure - wait for BaseApi to handle reconnection
console.log("Error:", e);
console.log(
"Message send failure, waiting for socket reconnection...",
);
// Schedule retry with backoff - let BaseApi handle the reconnection
this.timeoutId = setTimeout(
this.attempt.bind(this),
this.calculateBackoff(),
);
}
} else {
// No WebSocket connection available or not ready
// Let BaseApi handle reconnection, just wait and retry
console.log("Request", this.mid, "waiting for socket reconnection...");
// Use consistent backoff for all waiting scenarios
setTimeout(this.attempt.bind(this), this.calculateBackoff());
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,3 @@
// Type definitions for TrustGraph client
export {};