diff --git a/.cursor/rules/networktssdk.mdc b/.cursor/rules/networktssdk.mdc new file mode 100644 index 0000000..90f22e4 --- /dev/null +++ b/.cursor/rules/networktssdk.mdc @@ -0,0 +1,89 @@ +--- +alwaysApply: true +--- + +# AI Instructions + +You have access to the **networktssdk** MCP (Model Context Protocol) server for this project. This MCP provides deep, pre-analyzed context about the codebase that is far more accurate than default file searching. + +## IMPORTANT: Always Use MCP First + +**Before making any code changes or answering questions about this codebase, ALWAYS consult the MCP tools first.** + +The MCP has pre-indexed the entire codebase with semantic understanding, embeddings, and structural analysis. While you can use your own file search capabilities, the MCP provides much better context because: +- It understands code semantics, not just text matching +- It has pre-analyzed the architecture, patterns, and relationships +- It can answer questions about intent and purpose, not just content + +## Available MCP Tools + +### Code Understanding +- `networktssdk_ask_question` - Ask natural language questions about the codebase. Use this for "how does X work?", "where is Y implemented?", "what does Z do?" questions. The MCP will search relevant code and provide informed answers. +- `networktssdk_search_code` - Semantic code search. Find code by meaning, not just text. Great for finding implementations, patterns, or related functionality. +- `networktssdk_get_architecture` - Get the full project architecture overview including tech stack, design patterns, domain entities, and API endpoints. +- `networktssdk_get_file_summary` - Get a detailed summary of what a specific file does, its purpose, exports, and responsibilities. +- `networktssdk_find_function` - Find a specific function or method definition by name across the codebase. +- `networktssdk_list_functions` - List all functions defined in a specific file. + +### Skills (Learned Procedures) +Skills are reusable procedures that the agent has learned about this specific project (e.g., "how to deploy", "how to run tests", "how to add a new API endpoint"). + +- `networktssdk_list_skills` - List all learned skills for this project. +- `networktssdk_get_skill` - Get detailed information about a specific skill including its step-by-step procedure. +- `networktssdk_execute_skill` - Get the procedure for a learned skill so you can execute it step by step. Returns prerequisites, warnings, and commands to run. +- `networktssdk_learn_skill` - Teach the agent a new skill. The agent will explore, discover, and memorize how to perform this task. +- `networktssdk_get_learning_status` - Check the status of an ongoing skill learning session. +- `networktssdk_answer_question` - Answer a question that the learning agent asked during skill learning. +- `networktssdk_cancel_learning` - Cancel an active learning session. +- `networktssdk_forget_skill` - Delete a learned skill. +- `networktssdk_update_skill` - Update a learned skill with corrections or new information (e.g., 'Use .env.prod instead of .env', 'Add step to backup database first', 'The port should be 3000 not 8080'). + +#### Skill Learning Workflow (IMPORTANT) + +When learning a skill, follow this **collaborative, goal-oriented workflow**. You (Cursor) are the executor, the MCP agent provides guidance: + +**Goal-Oriented Learning**: The agent identifies specific GOALS (pieces of information to gather) and tracks progress by goal completion, not by iterations. + +1. **Start Learning**: Call `learn_skill` with name and detailed description +2. **Monitor Progress**: Call `get_learning_status` to check progress +3. **Handle Status Responses**: + - `active` → Learning in progress, check again in a few seconds + - `waiting_input` → The agent has a question. Read it and call `answer_question` with your response + - `waiting_execution` → **IMPORTANT**: The agent needs you to run a command! + - Read the `pendingExecution.command` from the response + - **Execute the command yourself** using your terminal access + - Call `answer_question` with the command output + - `completed` → Skill learned successfully! + - `failed` → Check errors and try again +4. **Repeat** steps 2-3 until status is `completed` + +**Key Insight**: The MCP agent runs on the server and cannot SSH to remote servers directly. When it needs remote access, it generates the SSH command for YOU to execute. You have terminal access - use it! + +**User Override Commands**: If the agent gets stuck, you can include these keywords in your answer: +- `COMPLETE` or `SKIP` - Skip to synthesis phase and generate the skill from current data +- `PHASE:synthesizing` - Force transition to drafting phase +- `GOAL:goal_id=value` - Directly provide a goal's value (e.g., `GOAL:cluster_secret=abc123`) +- `I have provided X` - Tell the agent it already has certain information + +**Example for `waiting_execution`**: +``` +// Status response shows: +// pendingExecution: { command: "ssh root@192.168.1.1 'ls -la /home/user/.orama'" } +// +// You should: +// 1. Run the command in your terminal +// 2. Get the output +// 3. Call answer_question with the output +``` + +## Recommended Workflow + +1. **For questions:** Use `networktssdk_ask_question` or `networktssdk_search_code` to understand the codebase. +--- + +# Polybase SDK (or similar Decentralized Web3 SDK) + +This project is a TypeScript-based Software Development Kit (SDK) designed to provide a high-level interface for interacting with a decentralized backend infrastructure. It abstracts complex networking, security, and data persistence tasks into a suite of client modules, allowing developers to manage authentication, execute SQL-like queries against a distributed database, interact with P2P networks, and handle real-time messaging via WebSockets. The SDK focuses on providing a familiar, developer-friendly experience (similar to Firebase or Supabase) but tailored for decentralized environments, featuring built-in support for caching, file storage, and identity verification. + +**Architecture:** Client-Side SDK (Modular Library) + diff --git a/README.md b/README.md index a722fcb..d35b1d4 100644 --- a/README.md +++ b/README.md @@ -215,6 +215,55 @@ const topics = await client.pubsub.topics(); console.log("Active topics:", topics); ``` +### Presence Support + +The SDK supports real-time presence tracking, allowing you to see who is currently subscribed to a topic. + +#### Subscribe with Presence + +Enable presence by providing `presence` options in `subscribe`: + +```typescript +const subscription = await client.pubsub.subscribe("room.123", { + onMessage: (msg) => console.log("Message:", msg.data), + presence: { + enabled: true, + memberId: "user-alice", + meta: { displayName: "Alice", avatar: "URL" }, + onJoin: (member) => { + console.log(`${member.memberId} joined at ${new Date(member.joinedAt)}`); + console.log("Meta:", member.meta); + }, + onLeave: (member) => { + console.log(`${member.memberId} left`); + }, + }, +}); +``` + +#### Get Presence for a Topic + +Query current members without subscribing: + +```typescript +const presence = await client.pubsub.getPresence("room.123"); +console.log(`Total members: ${presence.count}`); +presence.members.forEach((member) => { + console.log(`- ${member.memberId} (joined: ${new Date(member.joinedAt)})`); +}); +``` + +#### Subscription Helpers + +Get presence information from an active subscription: + +```typescript +if (subscription.hasPresence()) { + const members = await subscription.getPresence(); + console.log("Current members:", members); +} +``` + ### Authentication #### Switch API Key diff --git a/src/functions/client.ts b/src/functions/client.ts new file mode 100644 index 0000000..4cf10c8 --- /dev/null +++ b/src/functions/client.ts @@ -0,0 +1,62 @@ +/** + * Functions Client + * Client for calling serverless functions on the Orama Network + */ + +import { HttpClient } from "../core/http"; +import { SDKError } from "../errors"; + +export interface FunctionsClientConfig { + /** + * Base URL for the functions gateway + * Defaults to using the same baseURL as the HTTP client + */ + gatewayURL?: string; + + /** + * Namespace for the functions + */ + namespace: string; +} + +export class FunctionsClient { + private httpClient: HttpClient; + private gatewayURL?: string; + private namespace: string; + + constructor(httpClient: HttpClient, config?: FunctionsClientConfig) { + this.httpClient = httpClient; + this.gatewayURL = config?.gatewayURL; + this.namespace = config?.namespace ?? "default"; + } + + /** + * Invoke a serverless function by name + * + * @param functionName - Name of the function to invoke + * @param input - Input payload for the function + * @returns The function response + */ + async invoke( + functionName: string, + input: TInput + ): Promise { + const url = this.gatewayURL + ? `${this.gatewayURL}/v1/invoke/${this.namespace}/${functionName}` + : `/v1/invoke/${this.namespace}/${functionName}`; + + try { + const response = await this.httpClient.post(url, input); + return response; + } catch (error) { + if (error instanceof SDKError) { + throw error; + } + throw new SDKError( + `Function ${functionName} failed`, + 500, + error instanceof Error ? error.message : String(error) + ); + } + } +} diff --git a/src/functions/types.ts b/src/functions/types.ts new file mode 100644 index 0000000..4bd1688 --- /dev/null +++ b/src/functions/types.ts @@ -0,0 +1,21 @@ +/** + * Serverless Functions Types + * Type definitions for calling serverless functions on the Orama Network + */ + +/** + * Generic response from a serverless function + */ +export interface FunctionResponse { + success: boolean; + error?: string; + data?: T; +} + +/** + * Standard success/error response used by many functions + */ +export interface SuccessResponse { + success: boolean; + error?: string; +} diff --git a/src/index.ts b/src/index.ts index d224e86..4b4c632 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,7 @@ import { PubSubClient } from "./pubsub/client"; import { NetworkClient } from "./network/client"; import { CacheClient } from "./cache/client"; import { StorageClient } from "./storage/client"; +import { FunctionsClient, FunctionsClientConfig } from "./functions/client"; import { WSClientConfig } from "./core/ws"; import { StorageAdapter, @@ -17,6 +18,7 @@ export interface ClientConfig extends Omit { jwt?: string; storage?: StorageAdapter; wsConfig?: Partial; + functionsConfig?: FunctionsClientConfig; fetch?: typeof fetch; } @@ -27,6 +29,7 @@ export interface Client { network: NetworkClient; cache: CacheClient; storage: StorageClient; + functions: FunctionsClient; } export function createClient(config: ClientConfig): Client { @@ -58,6 +61,7 @@ export function createClient(config: ClientConfig): Client { const network = new NetworkClient(httpClient); const cache = new CacheClient(httpClient); const storage = new StorageClient(httpClient); + const functions = new FunctionsClient(httpClient, config.functionsConfig); return { auth, @@ -66,6 +70,7 @@ export function createClient(config: ClientConfig): Client { network, cache, storage, + functions, }; } @@ -79,16 +84,21 @@ export { PubSubClient, Subscription } from "./pubsub/client"; export { NetworkClient } from "./network/client"; export { CacheClient } from "./cache/client"; export { StorageClient } from "./storage/client"; +export { FunctionsClient } from "./functions/client"; export { SDKError } from "./errors"; export { MemoryStorage, LocalStorageAdapter } from "./auth/types"; export type { StorageAdapter, AuthConfig, WhoAmI } from "./auth/types"; export type * from "./db/types"; export type { - Message, MessageHandler, ErrorHandler, CloseHandler, -} from "./pubsub/client"; + PresenceMember, + PresenceResponse, + PresenceOptions, + SubscribeOptions, +} from "./pubsub/types"; +export { type PubSubMessage } from "./pubsub/types"; export type { PeerInfo, NetworkStatus, @@ -114,3 +124,5 @@ export type { StoragePinResponse, StorageStatus, } from "./storage/client"; +export type { FunctionsClientConfig } from "./functions/client"; +export type * from "./functions/types"; diff --git a/src/pubsub/client.ts b/src/pubsub/client.ts index f059313..1805a30 100644 --- a/src/pubsub/client.ts +++ b/src/pubsub/client.ts @@ -1,17 +1,16 @@ import { HttpClient } from "../core/http"; import { WSClient, WSClientConfig } from "../core/ws"; - -export interface Message { - data: string; - topic: string; - timestamp: number; -} - -export interface RawEnvelope { - data: string; // base64-encoded - timestamp: number; - topic: string; -} +import { + PubSubMessage, + RawEnvelope, + MessageHandler, + ErrorHandler, + CloseHandler, + SubscribeOptions, + PresenceResponse, + PresenceMember, + PresenceOptions, +} from "./types"; // Cross-platform base64 encoding/decoding utilities function base64Encode(str: string): string { @@ -54,10 +53,6 @@ function base64Decode(b64: string): string { throw new Error("No base64 decoding method available"); } -export type MessageHandler = (message: Message) => void; -export type ErrorHandler = (error: Error) => void; -export type CloseHandler = () => void; - /** * Simple PubSub client - one WebSocket connection per topic * No connection pooling, no reference counting - keep it simple @@ -104,23 +99,40 @@ export class PubSubClient { return response.topics || []; } + /** + * Get current presence for a topic without subscribing + */ + async getPresence(topic: string): Promise { + const response = await this.httpClient.get( + `/v1/pubsub/presence?topic=${encodeURIComponent(topic)}` + ); + return response; + } + /** * Subscribe to a topic via WebSocket * Creates one WebSocket connection per topic */ async subscribe( topic: string, - handlers: { - onMessage?: MessageHandler; - onError?: ErrorHandler; - onClose?: CloseHandler; - } = {} + options: SubscribeOptions = {} ): Promise { // Build WebSocket URL for this topic const wsUrl = new URL(this.wsConfig.wsURL || "ws://127.0.0.1:6001"); wsUrl.pathname = "/v1/pubsub/ws"; wsUrl.searchParams.set("topic", topic); + // Handle presence options + let presence: PresenceOptions | undefined; + if (options.presence?.enabled) { + presence = options.presence; + wsUrl.searchParams.set("presence", "true"); + wsUrl.searchParams.set("member_id", presence.memberId); + if (presence.meta) { + wsUrl.searchParams.set("member_meta", JSON.stringify(presence.meta)); + } + } + const authToken = this.httpClient.getApiKey() ?? this.httpClient.getToken(); // Create WebSocket client @@ -133,16 +145,18 @@ export class PubSubClient { await wsClient.connect(); // Create subscription wrapper - const subscription = new Subscription(wsClient, topic); + const subscription = new Subscription(wsClient, topic, presence, () => + this.getPresence(topic) + ); - if (handlers.onMessage) { - subscription.onMessage(handlers.onMessage); + if (options.onMessage) { + subscription.onMessage(options.onMessage); } - if (handlers.onError) { - subscription.onError(handlers.onError); + if (options.onError) { + subscription.onError(options.onError); } - if (handlers.onClose) { - subscription.onClose(handlers.onClose); + if (options.onClose) { + subscription.onClose(options.onClose); } return subscription; @@ -155,6 +169,7 @@ export class PubSubClient { export class Subscription { private wsClient: WSClient; private topic: string; + private presenceOptions?: PresenceOptions; private messageHandlers: Set = new Set(); private errorHandlers: Set = new Set(); private closeHandlers: Set = new Set(); @@ -162,10 +177,18 @@ export class Subscription { private wsMessageHandler: ((data: string) => void) | null = null; private wsErrorHandler: ((error: Error) => void) | null = null; private wsCloseHandler: (() => void) | null = null; + private getPresenceFn: () => Promise; - constructor(wsClient: WSClient, topic: string) { + constructor( + wsClient: WSClient, + topic: string, + presenceOptions: PresenceOptions | undefined, + getPresenceFn: () => Promise + ) { this.wsClient = wsClient; this.topic = topic; + this.presenceOptions = presenceOptions; + this.getPresenceFn = getPresenceFn; // Register message handler this.wsMessageHandler = (data) => { @@ -177,6 +200,37 @@ export class Subscription { if (!envelope || typeof envelope !== "object") { throw new Error("Invalid envelope: not an object"); } + + // Handle presence events + if ( + envelope.type === "presence.join" || + envelope.type === "presence.leave" + ) { + if (!envelope.member_id) { + console.warn("[Subscription] Presence event missing member_id"); + return; + } + + const presenceMember: PresenceMember = { + memberId: envelope.member_id, + joinedAt: envelope.timestamp, + meta: envelope.meta, + }; + + if ( + envelope.type === "presence.join" && + this.presenceOptions?.onJoin + ) { + this.presenceOptions.onJoin(presenceMember); + } else if ( + envelope.type === "presence.leave" && + this.presenceOptions?.onLeave + ) { + this.presenceOptions.onLeave(presenceMember); + } + return; // Don't call regular onMessage for presence events + } + if (!envelope.data || typeof envelope.data !== "string") { throw new Error("Invalid envelope: missing or invalid data field"); } @@ -192,7 +246,7 @@ export class Subscription { // Decode base64 data const messageData = base64Decode(envelope.data); - const message: Message = { + const message: PubSubMessage = { topic: envelope.topic, data: messageData, timestamp: envelope.timestamp, @@ -223,6 +277,25 @@ export class Subscription { this.wsClient.onClose(this.wsCloseHandler); } + /** + * Get current presence (requires presence.enabled on subscribe) + */ + async getPresence(): Promise { + if (!this.presenceOptions?.enabled) { + throw new Error("Presence is not enabled for this subscription"); + } + + const response = await this.getPresenceFn(); + return response.members; + } + + /** + * Check if presence is enabled for this subscription + */ + hasPresence(): boolean { + return !!this.presenceOptions?.enabled; + } + /** * Register message handler */ diff --git a/src/pubsub/types.ts b/src/pubsub/types.ts new file mode 100644 index 0000000..304a273 --- /dev/null +++ b/src/pubsub/types.ts @@ -0,0 +1,46 @@ +export interface PubSubMessage { + data: string; + topic: string; + timestamp: number; +} + +export interface RawEnvelope { + type?: string; + data: string; // base64-encoded + timestamp: number; + topic: string; + member_id?: string; + meta?: Record; +} + +export interface PresenceMember { + memberId: string; + joinedAt: number; + meta?: Record; +} + +export interface PresenceResponse { + topic: string; + members: PresenceMember[]; + count: number; +} + +export interface PresenceOptions { + enabled: boolean; + memberId: string; + meta?: Record; + onJoin?: (member: PresenceMember) => void; + onLeave?: (member: PresenceMember) => void; +} + +export interface SubscribeOptions { + onMessage?: MessageHandler; + onError?: ErrorHandler; + onClose?: CloseHandler; + presence?: PresenceOptions; +} + +export type MessageHandler = (message: PubSubMessage) => void; +export type ErrorHandler = (error: Error) => void; +export type CloseHandler = () => void; + diff --git a/tests/e2e/pubsub.test.ts b/tests/e2e/pubsub.test.ts index f76b071..134d713 100644 --- a/tests/e2e/pubsub.test.ts +++ b/tests/e2e/pubsub.test.ts @@ -88,4 +88,44 @@ describe("PubSub", () => { expect(events.length).toBeGreaterThanOrEqual(0); }); + + it("should get presence information", async () => { + const client = await createTestClient(); + const presence = await client.pubsub.getPresence(topicName); + expect(presence.topic).toBe(topicName); + expect(Array.isArray(presence.members)).toBe(true); + expect(typeof presence.count).toBe("number"); + }); + + it("should handle presence events in subscription", async () => { + const client = await createTestClient(); + const joinedMembers: any[] = []; + const leftMembers: any[] = []; + const memberId = "test-user-" + Math.random().toString(36).substring(7); + const meta = { name: "Test User" }; + + const subscription = await client.pubsub.subscribe(topicName, { + presence: { + enabled: true, + memberId, + meta, + onJoin: (member) => joinedMembers.push(member), + onLeave: (member) => leftMembers.push(member), + }, + }); + + expect(subscription.hasPresence()).toBe(true); + + // Wait for join event + await delay(1000); + + // Some gateways might send the self-join event + // Check if we can get presence from subscription + const members = await subscription.getPresence(); + expect(Array.isArray(members)).toBe(true); + + // Cleanup + subscription.close(); + await delay(500); + }); });