Init Network Typescript SDK

This commit is contained in:
anonpenguin23 2025-10-22 09:06:42 +03:00
commit ddec9f6733
29 changed files with 5166 additions and 0 deletions

4
.env.example Normal file
View File

@ -0,0 +1,4 @@
# Gateway Configuration
GATEWAY_BASE_URL=http://localhost:6001
GATEWAY_API_KEY=ak_your_api_key:default
# GATEWAY_JWT=your_jwt_token (optional)

18
.gitignore vendored Normal file
View File

@ -0,0 +1,18 @@
node_modules/
dist/
build/
.DS_Store
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
.idea/
.vscode/
*.swp
*.swo
*~
.env
.env.local
coverage/
.nyc_output/

2
.npmrc Normal file
View File

@ -0,0 +1,2 @@
@network:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${NPM_TOKEN}

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) [year] [fullname]
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

170
QUICKSTART.md Normal file
View File

@ -0,0 +1,170 @@
# Quick Start Guide for @network/sdk
## 5-Minute Setup
### 1. Install
```bash
npm install @network/sdk
```
### 2. Create a Client
```typescript
import { createClient } from "@network/sdk";
const client = createClient({
baseURL: "http://localhost:6001",
apiKey: "ak_your_api_key:default", // Get from gateway
});
```
### 3. Use It
**Database:**
```typescript
await client.db.createTable("CREATE TABLE posts (id INTEGER PRIMARY KEY, title TEXT)");
await client.db.exec("INSERT INTO posts (title) VALUES (?)", ["Hello"]);
const posts = await client.db.query("SELECT * FROM posts");
```
**Pub/Sub:**
```typescript
const sub = await client.pubsub.subscribe("news", {
onMessage: (msg) => console.log(msg.data),
});
await client.pubsub.publish("news", "Update!");
sub.close();
```
**Network:**
```typescript
const healthy = await client.network.health();
const status = await client.network.status();
```
## Running Tests Locally
### Prerequisites
1. Bootstrap node must be running (provides database on port 5001)
2. Gateway must be running (provides REST API on port 6001)
```bash
# Terminal 1: Start bootstrap node
cd ../network
make run-node
# Terminal 2: Start gateway (after bootstrap is ready)
cd ../network
make run-gateway
# Terminal 3: Run E2E tests
cd ../network-ts-sdk
export GATEWAY_BASE_URL=http://localhost:6001
export GATEWAY_API_KEY=ak_RsJJXoENynk_5jTJEeM4wJKx:default
pnpm run test:e2e
```
**Note**: The gateway configuration now correctly uses port 5001 for RQLite (not 4001 which is P2P).
## Building for Production
```bash
npm run build
# Output in dist/
```
## Key Classes
| Class | Purpose |
|-------|---------|
| `createClient()` | Factory function, returns `Client` |
| `AuthClient` | Authentication, token management |
| `DBClient` | Database operations (exec, query, etc.) |
| `QueryBuilder` | Fluent SELECT builder |
| `Repository<T>` | Generic entity pattern |
| `PubSubClient` | Pub/sub operations |
| `NetworkClient` | Network status, peers |
| `SDKError` | All errors inherit from this |
## Common Patterns
### QueryBuilder
```typescript
const items = await client.db
.createQueryBuilder("items")
.where("status = ?", ["active"])
.andWhere("price > ?", [10])
.orderBy("created_at DESC")
.limit(20)
.getMany();
```
### Repository
```typescript
interface User { id?: number; email: string; }
const repo = client.db.repository<User>("users");
// Save (insert or update)
const user: User = { email: "alice@example.com" };
await repo.save(user);
// Find
const found = await repo.findOne({ email: "alice@example.com" });
```
### Transaction
```typescript
await client.db.transaction([
{ kind: "exec", sql: "INSERT INTO logs (msg) VALUES (?)", args: ["Event A"] },
{ kind: "query", sql: "SELECT COUNT(*) FROM logs", args: [] },
]);
```
### Error Handling
```typescript
import { SDKError } from "@network/sdk";
try {
await client.db.query("SELECT * FROM invalid_table");
} catch (error) {
if (error instanceof SDKError) {
console.error(`${error.httpStatus}: ${error.message}`);
}
}
```
## TypeScript Types
Full type safety - use autocomplete in your IDE:
```typescript
const status: NetworkStatus = await client.network.status();
const users: User[] = await repo.find({ active: 1 });
const msg: Message = await subscription.onMessage((m) => m);
```
## Next Steps
1. Read the full [README.md](./README.md)
2. Explore [tests/e2e/](./tests/e2e/) for examples
3. Check [IMPLEMENTATION_SUMMARY.md](./IMPLEMENTATION_SUMMARY.md) for architecture
## Troubleshooting
**"Failed to connect to gateway"**
- Check `GATEWAY_BASE_URL` is correct
- Ensure gateway is running
- Verify network connectivity
**"API key invalid"**
- Confirm `apiKey` format: `ak_key:namespace`
- Get a fresh API key from gateway admin
**"WebSocket connection failed"**
- Gateway must support WebSocket at `/v1/pubsub/ws`
- Check firewall settings
**"Tests skip"**
- Set `GATEWAY_API_KEY` environment variable
- Tests gracefully skip without it

329
README.md Normal file
View File

@ -0,0 +1,329 @@
# @network/sdk - TypeScript SDK for DeBros Network
A modern, isomorphic TypeScript SDK for the DeBros Network gateway. Works seamlessly in both Node.js and browser environments with support for database operations, pub/sub messaging, and network management.
## Features
- **Isomorphic**: Works in Node.js and browsers (uses fetch and isomorphic-ws)
- **Database ORM-like API**: QueryBuilder, Repository pattern, transactions
- **Pub/Sub Messaging**: WebSocket subscriptions with automatic reconnection
- **Authentication**: API key and JWT support with automatic token management
- **TypeScript First**: Full type safety and IntelliSense
- **Error Handling**: Unified SDKError with HTTP status and code
## Installation
```bash
npm install @network/network-ts-sdk
```
## Quick Start
### Initialize the Client
```typescript
import { createClient } from "@network/sdk";
const client = createClient({
baseURL: "http://localhost:6001",
apiKey: "ak_your_api_key:namespace",
});
// Or with JWT
const client = createClient({
baseURL: "http://localhost:6001",
jwt: "your_jwt_token",
});
```
### Database Operations
#### Create a Table
```typescript
await client.db.createTable(
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, email TEXT)"
);
```
#### Insert Data
```typescript
const result = await client.db.exec(
"INSERT INTO users (name, email) VALUES (?, ?)",
["Alice", "alice@example.com"]
);
console.log(result.last_insert_id);
```
#### Query Data
```typescript
const users = await client.db.query("SELECT * FROM users WHERE email = ?", [
"alice@example.com",
]);
```
#### Using QueryBuilder
```typescript
const activeUsers = await client.db
.createQueryBuilder("users")
.where("active = ?", [1])
.orderBy("name DESC")
.limit(10)
.getMany();
const firstUser = await client.db
.createQueryBuilder("users")
.where("id = ?", [1])
.getOne();
```
#### Using Repository Pattern
```typescript
interface User {
id?: number;
name: string;
email: string;
}
const repo = client.db.repository<User>("users");
// Find
const users = await repo.find({ active: 1 });
const user = await repo.findOne({ email: "alice@example.com" });
// Save (INSERT or UPDATE)
const newUser: User = { name: "Bob", email: "bob@example.com" };
await repo.save(newUser);
// Remove
await repo.remove(newUser);
```
#### Transactions
```typescript
const results = await client.db.transaction([
{
kind: "exec",
sql: "INSERT INTO users (name, email) VALUES (?, ?)",
args: ["Charlie", "charlie@example.com"],
},
{
kind: "query",
sql: "SELECT COUNT(*) as count FROM users",
args: [],
},
]);
```
### Pub/Sub Messaging
#### Publish a Message
```typescript
await client.pubsub.publish("notifications", "Hello, Network!");
```
#### Subscribe to Topics
```typescript
const subscription = await client.pubsub.subscribe("notifications", {
onMessage: (msg) => {
console.log("Received:", msg.data);
},
onError: (err) => {
console.error("Subscription error:", err);
},
onClose: () => {
console.log("Subscription closed");
},
});
// Later, close the subscription
subscription.close();
```
#### List Topics
```typescript
const topics = await client.pubsub.topics();
console.log("Active topics:", topics);
```
### Authentication
#### Switch API Key
```typescript
client.auth.setApiKey("ak_new_key:namespace");
```
#### Switch JWT
```typescript
client.auth.setJwt("new_jwt_token");
```
#### Get Current Token
```typescript
const token = client.auth.getToken(); // Returns API key or JWT
```
#### Get Authentication Info
```typescript
const info = await client.auth.whoami();
console.log(info.authenticated, info.namespace);
```
#### Logout
```typescript
await client.auth.logout();
```
### Network Operations
#### Check Health
```typescript
const healthy = await client.network.health();
```
#### Get Network Status
```typescript
const status = await client.network.status();
console.log(status.healthy, status.peers);
```
#### List Peers
```typescript
const peers = await client.network.peers();
peers.forEach((peer) => {
console.log(peer.id, peer.addresses);
});
```
## Configuration
### ClientConfig
```typescript
interface ClientConfig {
baseURL: string; // Gateway URL
apiKey?: string; // API key (optional, if using JWT instead)
jwt?: string; // JWT token (optional, if using API key instead)
timeout?: number; // Request timeout in ms (default: 30000)
maxRetries?: number; // Max retry attempts (default: 3)
retryDelayMs?: number; // Delay between retries (default: 1000)
storage?: StorageAdapter; // For persisting JWT/API key (default: MemoryStorage)
wsConfig?: Partial<WSClientConfig>; // WebSocket configuration
fetch?: typeof fetch; // Custom fetch implementation
}
```
### Storage Adapters
By default, credentials are stored in memory. For browser apps, use localStorage:
```typescript
import { createClient, LocalStorageAdapter } from "@network/sdk";
const client = createClient({
baseURL: "http://localhost:6001",
storage: new LocalStorageAdapter(),
apiKey: "ak_your_key:namespace",
});
```
## Error Handling
The SDK throws `SDKError` for all errors:
```typescript
import { SDKError } from "@network/sdk";
try {
await client.db.query("SELECT * FROM nonexistent");
} catch (error) {
if (error instanceof SDKError) {
console.log(error.httpStatus); // e.g., 400
console.log(error.code); // e.g., "HTTP_400"
console.log(error.message); // Error message
console.log(error.details); // Full error response
}
}
```
## Browser Usage
The SDK works in browsers with minimal setup:
```typescript
// Browser example
import { createClient } from "@network/sdk";
const client = createClient({
baseURL: "https://gateway.example.com",
apiKey: "ak_browser_key:my-app",
});
// Use like any other API client
const data = await client.db.query("SELECT * FROM items");
```
**Note**: For WebSocket connections in browsers with authentication, ensure your gateway supports either header-based auth or query parameter auth.
## Testing
Run E2E tests against a running gateway:
```bash
# Set environment variables
export GATEWAY_BASE_URL=http://localhost:6001
export GATEWAY_API_KEY=ak_test_key:default
# Run tests
npm run test:e2e
```
## Examples
See the `tests/e2e/` directory for complete examples of:
- Authentication (`auth.test.ts`)
- Database operations (`db.test.ts`)
- Transactions (`tx.test.ts`)
- Pub/Sub messaging (`pubsub.test.ts`)
- Network operations (`network.test.ts`)
## Building
```bash
npm run build
```
Output goes to `dist/` with ESM and type declarations.
## Development
```bash
npm run dev # Watch mode
npm run typecheck # Type checking
npm run lint # Linting (if configured)
```
## License
MIT
## Support
For issues, questions, or contributions, please open an issue on GitHub or visit [DeBros Network Documentation](https://network.debros.io/docs/).

42
package.json Normal file
View File

@ -0,0 +1,42 @@
{
"name": "@network/network-ts-sdk",
"version": "0.0.1",
"description": "TypeScript SDK for DeBros Network Gateway",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"files": [
"dist",
"src"
],
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"typecheck": "tsc --noEmit",
"lint": "eslint src tests",
"test": "vitest",
"test:e2e": "vitest run tests/e2e"
},
"dependencies": {
"isomorphic-ws": "^5.0.0"
},
"devDependencies": {
"@types/node": "^20.0.0",
"typescript": "^5.3.0",
"tsup": "^8.0.0",
"vitest": "^1.0.0",
"eslint": "^8.0.0",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0"
},
"publishConfig": {
"registry": "https://registry.npmjs.org/",
"access": "public"
}
}

2845
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

97
src/auth/client.ts Normal file
View File

@ -0,0 +1,97 @@
import { HttpClient } from "../core/http";
import {
AuthConfig,
WhoAmI,
StorageAdapter,
MemoryStorage,
} from "./types";
export class AuthClient {
private httpClient: HttpClient;
private storage: StorageAdapter;
private currentApiKey?: string;
private currentJwt?: string;
constructor(config: {
httpClient: HttpClient;
storage?: StorageAdapter;
apiKey?: string;
jwt?: string;
}) {
this.httpClient = config.httpClient;
this.storage = config.storage ?? new MemoryStorage();
this.currentApiKey = config.apiKey;
this.currentJwt = config.jwt;
if (this.currentApiKey) {
this.httpClient.setApiKey(this.currentApiKey);
}
if (this.currentJwt) {
this.httpClient.setJwt(this.currentJwt);
}
}
setApiKey(apiKey: string) {
this.currentApiKey = apiKey;
this.currentJwt = undefined;
this.httpClient.setApiKey(apiKey);
this.storage.set("apiKey", apiKey);
}
setJwt(jwt: string) {
this.currentJwt = jwt;
this.currentApiKey = undefined;
this.httpClient.setJwt(jwt);
this.storage.set("jwt", jwt);
}
getToken(): string | undefined {
return this.httpClient.getToken();
}
async whoami(): Promise<WhoAmI> {
try {
const response = await this.httpClient.get<WhoAmI>("/v1/auth/whoami");
return response;
} catch {
return { authenticated: false };
}
}
async refresh(): Promise<string> {
const response = await this.httpClient.post<{ token: string }>(
"/v1/auth/refresh"
);
const token = response.token;
this.setJwt(token);
return token;
}
async logout(): Promise<void> {
// Only attempt server-side logout if using JWT
// API keys don't support server-side logout with all=true
if (this.currentJwt) {
try {
await this.httpClient.post("/v1/auth/logout", { all: true });
} catch (error) {
// Log warning but don't fail - local cleanup is more important
console.warn('Server-side logout failed, continuing with local cleanup:', error);
}
}
// Always clear local state
this.currentApiKey = undefined;
this.currentJwt = undefined;
this.httpClient.setApiKey(undefined);
this.httpClient.setJwt(undefined);
await this.storage.clear();
}
async clear(): Promise<void> {
this.currentApiKey = undefined;
this.currentJwt = undefined;
this.httpClient.setApiKey(undefined);
this.httpClient.setJwt(undefined);
await this.storage.clear();
}
}

62
src/auth/types.ts Normal file
View File

@ -0,0 +1,62 @@
export interface AuthConfig {
apiKey?: string;
jwt?: string;
}
export interface WhoAmI {
address?: string;
namespace?: string;
authenticated: boolean;
}
export interface StorageAdapter {
get(key: string): Promise<string | null>;
set(key: string, value: string): Promise<void>;
clear(): Promise<void>;
}
export class MemoryStorage implements StorageAdapter {
private storage: Map<string, string> = new Map();
async get(key: string): Promise<string | null> {
return this.storage.get(key) ?? null;
}
async set(key: string, value: string): Promise<void> {
this.storage.set(key, value);
}
async clear(): Promise<void> {
this.storage.clear();
}
}
export class LocalStorageAdapter implements StorageAdapter {
private prefix = "@network/sdk:";
async get(key: string): Promise<string | null> {
if (typeof globalThis !== "undefined" && globalThis.localStorage) {
return globalThis.localStorage.getItem(this.prefix + key);
}
return null;
}
async set(key: string, value: string): Promise<void> {
if (typeof globalThis !== "undefined" && globalThis.localStorage) {
globalThis.localStorage.setItem(this.prefix + key, value);
}
}
async clear(): Promise<void> {
if (typeof globalThis !== "undefined" && globalThis.localStorage) {
const keysToDelete: string[] = [];
for (let i = 0; i < globalThis.localStorage.length; i++) {
const key = globalThis.localStorage.key(i);
if (key?.startsWith(this.prefix)) {
keysToDelete.push(key);
}
}
keysToDelete.forEach((key) => globalThis.localStorage.removeItem(key));
}
}
}

158
src/core/http.ts Normal file
View File

@ -0,0 +1,158 @@
import { SDKError } from "../errors";
export interface HttpClientConfig {
baseURL: string;
timeout?: number;
maxRetries?: number;
retryDelayMs?: number;
fetch?: typeof fetch;
}
export class HttpClient {
private baseURL: string;
private timeout: number;
private maxRetries: number;
private retryDelayMs: number;
private fetch: typeof fetch;
private apiKey?: string;
private jwt?: string;
constructor(config: HttpClientConfig) {
this.baseURL = config.baseURL.replace(/\/$/, "");
this.timeout = config.timeout ?? 30000;
this.maxRetries = config.maxRetries ?? 3;
this.retryDelayMs = config.retryDelayMs ?? 1000;
this.fetch = config.fetch ?? globalThis.fetch;
}
setApiKey(apiKey?: string) {
this.apiKey = apiKey;
this.jwt = undefined;
}
setJwt(jwt?: string) {
this.jwt = jwt;
this.apiKey = undefined;
}
private getAuthHeaders(): Record<string, string> {
const headers: Record<string, string> = {};
if (this.jwt) {
headers["Authorization"] = `Bearer ${this.jwt}`;
} else if (this.apiKey) {
headers["X-API-Key"] = this.apiKey;
}
return headers;
}
private getAuthToken(): string | undefined {
return this.jwt || this.apiKey;
}
async request<T = any>(
method: "GET" | "POST" | "PUT" | "DELETE",
path: string,
options: {
body?: any;
headers?: Record<string, string>;
query?: Record<string, string | number | boolean>;
} = {}
): Promise<T> {
const url = new URL(this.baseURL + path);
if (options.query) {
Object.entries(options.query).forEach(([key, value]) => {
url.searchParams.append(key, String(value));
});
}
const headers: Record<string, string> = {
"Content-Type": "application/json",
...this.getAuthHeaders(),
...options.headers,
};
const fetchOptions: RequestInit = {
method,
headers,
signal: AbortSignal.timeout(this.timeout),
};
if (options.body !== undefined) {
fetchOptions.body = JSON.stringify(options.body);
}
return this.requestWithRetry(url.toString(), fetchOptions);
}
private async requestWithRetry(
url: string,
options: RequestInit,
attempt: number = 0
): Promise<any> {
try {
const response = await this.fetch(url, options);
if (!response.ok) {
let body: any;
try {
body = await response.json();
} catch {
body = { error: response.statusText };
}
throw SDKError.fromResponse(response.status, body);
}
const contentType = response.headers.get("content-type");
if (contentType?.includes("application/json")) {
return response.json();
}
return response.text();
} catch (error) {
if (
error instanceof SDKError &&
attempt < this.maxRetries &&
[408, 429, 500, 502, 503, 504].includes(error.httpStatus)
) {
await new Promise((resolve) =>
setTimeout(resolve, this.retryDelayMs * (attempt + 1))
);
return this.requestWithRetry(url, options, attempt + 1);
}
throw error;
}
}
async get<T = any>(
path: string,
options?: Omit<Parameters<typeof this.request>[2], "body">
): Promise<T> {
return this.request<T>("GET", path, options);
}
async post<T = any>(
path: string,
body?: any,
options?: Omit<Parameters<typeof this.request>[2], "body">
): Promise<T> {
return this.request<T>("POST", path, { ...options, body });
}
async put<T = any>(
path: string,
body?: any,
options?: Omit<Parameters<typeof this.request>[2], "body">
): Promise<T> {
return this.request<T>("PUT", path, { ...options, body });
}
async delete<T = any>(
path: string,
options?: Omit<Parameters<typeof this.request>[2], "body">
): Promise<T> {
return this.request<T>("DELETE", path, options);
}
getToken(): string | undefined {
return this.getAuthToken();
}
}

182
src/core/ws.ts Normal file
View File

@ -0,0 +1,182 @@
import WebSocket from "isomorphic-ws";
import { SDKError } from "../errors";
export interface WSClientConfig {
wsURL: string;
timeout?: number;
maxReconnectAttempts?: number;
reconnectDelayMs?: number;
heartbeatIntervalMs?: number;
authMode?: "header" | "query";
authToken?: string;
WebSocket?: typeof WebSocket;
}
export type WSMessageHandler = (data: string) => void;
export type WSErrorHandler = (error: Error) => void;
export type WSCloseHandler = () => void;
export class WSClient {
private url: string;
private timeout: number;
private maxReconnectAttempts: number;
private reconnectDelayMs: number;
private heartbeatIntervalMs: number;
private authMode: "header" | "query";
private authToken?: string;
private WebSocketClass: typeof WebSocket;
private ws?: WebSocket;
private reconnectAttempts = 0;
private heartbeatInterval?: NodeJS.Timeout;
private messageHandlers: Set<WSMessageHandler> = new Set();
private errorHandlers: Set<WSErrorHandler> = new Set();
private closeHandlers: Set<WSCloseHandler> = new Set();
private isManuallyClosed = false;
constructor(config: WSClientConfig) {
this.url = config.wsURL;
this.timeout = config.timeout ?? 30000;
this.maxReconnectAttempts = config.maxReconnectAttempts ?? 5;
this.reconnectDelayMs = config.reconnectDelayMs ?? 1000;
this.heartbeatIntervalMs = config.heartbeatIntervalMs ?? 30000;
this.authMode = config.authMode ?? "header";
this.authToken = config.authToken;
this.WebSocketClass = config.WebSocket ?? WebSocket;
}
connect(): Promise<void> {
return new Promise((resolve, reject) => {
try {
const wsUrl = this.buildWSUrl();
this.ws = new this.WebSocketClass(wsUrl);
// Note: Custom headers via ws library in Node.js are not sent with WebSocket upgrade requests
// so we rely on query parameters for authentication
const timeout = setTimeout(() => {
this.ws?.close();
reject(new SDKError("WebSocket connection timeout", 408, "WS_TIMEOUT"));
}, this.timeout);
this.ws.addEventListener("open", () => {
clearTimeout(timeout);
this.reconnectAttempts = 0;
this.startHeartbeat();
resolve();
});
this.ws.addEventListener("message", (event: Event) => {
const msgEvent = event as MessageEvent;
this.messageHandlers.forEach((handler) => handler(msgEvent.data));
});
this.ws.addEventListener("error", (event: Event) => {
clearTimeout(timeout);
const error = new SDKError(
"WebSocket error",
500,
"WS_ERROR",
event
);
this.errorHandlers.forEach((handler) => handler(error));
});
this.ws.addEventListener("close", () => {
clearTimeout(timeout);
this.stopHeartbeat();
if (!this.isManuallyClosed) {
this.attemptReconnect();
} else {
this.closeHandlers.forEach((handler) => handler());
}
});
} catch (error) {
reject(error);
}
});
}
private buildWSUrl(): string {
let url = this.url;
// Always append auth token as query parameter for compatibility
// Works in both Node.js and browser environments
if (this.authToken) {
const separator = url.includes("?") ? "&" : "?";
const paramName = this.authToken.startsWith("ak_") ? "api_key" : "token";
url += `${separator}${paramName}=${encodeURIComponent(this.authToken)}`;
}
return url;
}
private startHeartbeat() {
this.heartbeatInterval = setInterval(() => {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({ type: "ping" }));
}
}, this.heartbeatIntervalMs);
}
private stopHeartbeat() {
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
this.heartbeatInterval = undefined;
}
}
private attemptReconnect() {
if (this.reconnectAttempts < this.maxReconnectAttempts) {
this.reconnectAttempts++;
const delayMs = this.reconnectDelayMs * this.reconnectAttempts;
setTimeout(() => {
this.connect().catch((error) => {
this.errorHandlers.forEach((handler) => handler(error));
});
}, delayMs);
} else {
this.closeHandlers.forEach((handler) => handler());
}
}
onMessage(handler: WSMessageHandler) {
this.messageHandlers.add(handler);
return () => this.messageHandlers.delete(handler);
}
onError(handler: WSErrorHandler) {
this.errorHandlers.add(handler);
return () => this.errorHandlers.delete(handler);
}
onClose(handler: WSCloseHandler) {
this.closeHandlers.add(handler);
return () => this.closeHandlers.delete(handler);
}
send(data: string) {
if (this.ws?.readyState !== WebSocket.OPEN) {
throw new SDKError(
"WebSocket is not connected",
500,
"WS_NOT_CONNECTED"
);
}
this.ws.send(data);
}
close() {
this.isManuallyClosed = true;
this.stopHeartbeat();
this.ws?.close();
}
isConnected(): boolean {
return this.ws?.readyState === WebSocket.OPEN;
}
setAuthToken(token?: string) {
this.authToken = token;
}
}

126
src/db/client.ts Normal file
View File

@ -0,0 +1,126 @@
import { HttpClient } from "../core/http";
import { QueryBuilder } from "./qb";
import { Repository } from "./repository";
import {
QueryResponse,
TransactionOp,
TransactionRequest,
Entity,
FindOptions,
} from "./types";
export class DBClient {
private httpClient: HttpClient;
constructor(httpClient: HttpClient) {
this.httpClient = httpClient;
}
/**
* Execute a write/DDL SQL statement.
*/
async exec(
sql: string,
args: any[] = []
): Promise<{ rows_affected: number; last_insert_id?: number }> {
return this.httpClient.post("/v1/rqlite/exec", { sql, args });
}
/**
* Execute a SELECT query.
*/
async query<T = any>(sql: string, args: any[] = []): Promise<T[]> {
const response = await this.httpClient.post<QueryResponse>(
"/v1/rqlite/query",
{ sql, args }
);
return response.items || [];
}
/**
* Find rows with map-based criteria.
*/
async find<T = any>(
table: string,
criteria: Record<string, any> = {},
options: FindOptions = {}
): Promise<T[]> {
const response = await this.httpClient.post<QueryResponse>(
"/v1/rqlite/find",
{
table,
criteria,
options,
}
);
return response.items || [];
}
/**
* Find a single row with map-based criteria.
*/
async findOne<T = any>(
table: string,
criteria: Record<string, any>
): Promise<T | null> {
return this.httpClient.post<T | null>("/v1/rqlite/find-one", {
table,
criteria,
});
}
/**
* Create a fluent QueryBuilder for complex SELECT queries.
*/
createQueryBuilder(table: string): QueryBuilder {
return new QueryBuilder(this.httpClient, table);
}
/**
* Create a Repository for entity-based operations.
*/
repository<T extends Record<string, any>>(
tableName: string,
primaryKey = "id"
): Repository<T> {
return new Repository(this.httpClient, tableName, primaryKey);
}
/**
* Execute multiple operations atomically.
*/
async transaction(
ops: TransactionOp[],
returnResults = true
): Promise<any[]> {
const response = await this.httpClient.post<{ results?: any[] }>(
"/v1/rqlite/transaction",
{
ops,
return_results: returnResults,
}
);
return response.results || [];
}
/**
* Create a table from DDL SQL.
*/
async createTable(schema: string): Promise<void> {
await this.httpClient.post("/v1/rqlite/create-table", { schema });
}
/**
* Drop a table.
*/
async dropTable(table: string): Promise<void> {
await this.httpClient.post("/v1/rqlite/drop-table", { table });
}
/**
* Get current database schema.
*/
async getSchema(): Promise<any> {
return this.httpClient.get("/v1/rqlite/schema");
}
}

111
src/db/qb.ts Normal file
View File

@ -0,0 +1,111 @@
import { HttpClient } from "../core/http";
import { SelectOptions, QueryResponse } from "./types";
export class QueryBuilder {
private httpClient: HttpClient;
private table: string;
private options: SelectOptions = {};
constructor(httpClient: HttpClient, table: string) {
this.httpClient = httpClient;
this.table = table;
}
select(...columns: string[]): this {
this.options.select = columns;
return this;
}
innerJoin(table: string, on: string): this {
if (!this.options.joins) this.options.joins = [];
this.options.joins.push({ kind: "INNER", table, on });
return this;
}
leftJoin(table: string, on: string): this {
if (!this.options.joins) this.options.joins = [];
this.options.joins.push({ kind: "LEFT", table, on });
return this;
}
rightJoin(table: string, on: string): this {
if (!this.options.joins) this.options.joins = [];
this.options.joins.push({ kind: "RIGHT", table, on });
return this;
}
where(expr: string, args?: any[]): this {
if (!this.options.where) this.options.where = [];
this.options.where.push({ conj: "AND", expr, args });
return this;
}
andWhere(expr: string, args?: any[]): this {
return this.where(expr, args);
}
orWhere(expr: string, args?: any[]): this {
if (!this.options.where) this.options.where = [];
this.options.where.push({ conj: "OR", expr, args });
return this;
}
groupBy(...columns: string[]): this {
this.options.group_by = columns;
return this;
}
orderBy(...columns: string[]): this {
this.options.order_by = columns;
return this;
}
limit(n: number): this {
this.options.limit = n;
return this;
}
offset(n: number): this {
this.options.offset = n;
return this;
}
async getMany<T = any>(ctx?: any): Promise<T[]> {
const response = await this.httpClient.post<QueryResponse>(
"/v1/rqlite/select",
{
table: this.table,
...this.options,
}
);
return response.items || [];
}
async getOne<T = any>(ctx?: any): Promise<T | null> {
const response = await this.httpClient.post<QueryResponse>(
"/v1/rqlite/select",
{
table: this.table,
...this.options,
one: true,
limit: 1,
}
);
const items = response.items || [];
return items.length > 0 ? items[0] : null;
}
async count(): Promise<number> {
const response = await this.httpClient.post<QueryResponse>(
"/v1/rqlite/select",
{
table: this.table,
select: ["COUNT(*) AS count"],
where: this.options.where,
one: true,
}
);
const items = response.items || [];
return items.length > 0 ? items[0].count : 0;
}
}

124
src/db/repository.ts Normal file
View File

@ -0,0 +1,124 @@
import { HttpClient } from "../core/http";
import { QueryBuilder } from "./qb";
import { QueryResponse, FindOptions } from "./types";
import { SDKError } from "../errors";
export class Repository<T extends Record<string, any>> {
private httpClient: HttpClient;
private tableName: string;
private primaryKey: string;
constructor(httpClient: HttpClient, tableName: string, primaryKey = "id") {
this.httpClient = httpClient;
this.tableName = tableName;
this.primaryKey = primaryKey;
}
createQueryBuilder(): QueryBuilder {
return new QueryBuilder(this.httpClient, this.tableName);
}
async find(
criteria: Record<string, any> = {},
options: FindOptions = {}
): Promise<T[]> {
const response = await this.httpClient.post<QueryResponse>(
"/v1/rqlite/find",
{
table: this.tableName,
criteria,
options,
}
);
return response.items || [];
}
async findOne(criteria: Record<string, any>): Promise<T | null> {
try {
const response = await this.httpClient.post<T | null>(
"/v1/rqlite/find-one",
{
table: this.tableName,
criteria,
}
);
return response;
} catch (error) {
// Return null if not found instead of throwing
if (error instanceof SDKError && error.httpStatus === 404) {
return null;
}
throw error;
}
}
async save(entity: T): Promise<T> {
const pkValue = entity[this.primaryKey];
if (!pkValue) {
// INSERT
const response = await this.httpClient.post<{
rows_affected: number;
last_insert_id: number;
}>("/v1/rqlite/exec", {
sql: this.buildInsertSql(entity),
args: this.buildInsertArgs(entity),
});
if (response.last_insert_id) {
(entity as any)[this.primaryKey] = response.last_insert_id;
}
return entity;
} else {
// UPDATE
await this.httpClient.post("/v1/rqlite/exec", {
sql: this.buildUpdateSql(entity),
args: this.buildUpdateArgs(entity),
});
return entity;
}
}
async remove(entity: T | Record<string, any>): Promise<void> {
const pkValue = entity[this.primaryKey];
if (!pkValue) {
throw new SDKError(
`Primary key "${this.primaryKey}" is required for remove`,
400,
"MISSING_PK"
);
}
await this.httpClient.post("/v1/rqlite/exec", {
sql: `DELETE FROM ${this.tableName} WHERE ${this.primaryKey} = ?`,
args: [pkValue],
});
}
private buildInsertSql(entity: T): string {
const columns = Object.keys(entity).filter((k) => entity[k] !== undefined);
const placeholders = columns.map(() => "?").join(", ");
return `INSERT INTO ${this.tableName} (${columns.join(", ")}) VALUES (${placeholders})`;
}
private buildInsertArgs(entity: T): any[] {
return Object.entries(entity)
.filter(([, v]) => v !== undefined)
.map(([, v]) => v);
}
private buildUpdateSql(entity: T): string {
const columns = Object.keys(entity)
.filter((k) => entity[k] !== undefined && k !== this.primaryKey)
.map((k) => `${k} = ?`);
return `UPDATE ${this.tableName} SET ${columns.join(", ")} WHERE ${this.primaryKey} = ?`;
}
private buildUpdateArgs(entity: T): any[] {
const args = Object.entries(entity)
.filter(([k, v]) => v !== undefined && k !== this.primaryKey)
.map(([, v]) => v);
args.push(entity[this.primaryKey]);
return args;
}
}

67
src/db/types.ts Normal file
View File

@ -0,0 +1,67 @@
export interface Entity {
TableName(): string;
}
export interface QueryResponse {
columns?: string[];
rows?: any[][];
count?: number;
items?: any[];
}
export interface TransactionOp {
kind: "exec" | "query";
sql: string;
args?: any[];
}
export interface TransactionRequest {
statements?: string[];
ops?: TransactionOp[];
return_results?: boolean;
}
export interface SelectOptions {
select?: string[];
joins?: Array<{
kind: "INNER" | "LEFT" | "RIGHT" | "FULL";
table: string;
on: string;
}>;
where?: Array<{
conj?: "AND" | "OR";
expr: string;
args?: any[];
}>;
group_by?: string[];
order_by?: string[];
limit?: number;
offset?: number;
one?: boolean;
}
export type FindOptions = Omit<SelectOptions, "select" | "joins" | "one">;
export interface ColumnDefinition {
name: string;
isPrimaryKey?: boolean;
isAutoIncrement?: boolean;
}
export function extractTableName(entity: Entity | string): string {
if (typeof entity === "string") return entity;
return entity.TableName();
}
export function extractPrimaryKey(entity: any): string | undefined {
if (typeof entity === "string") return undefined;
// Check for explicit pk tag
const metadata = (entity as any)._dbMetadata;
if (metadata?.primaryKey) return metadata.primaryKey;
// Check for ID field
if (entity.id !== undefined) return "id";
return undefined;
}

38
src/errors.ts Normal file
View File

@ -0,0 +1,38 @@
export class SDKError extends Error {
public readonly httpStatus: number;
public readonly code: string;
public readonly details: Record<string, any>;
constructor(
message: string,
httpStatus: number = 500,
code: string = "SDK_ERROR",
details: Record<string, any> = {}
) {
super(message);
this.name = "SDKError";
this.httpStatus = httpStatus;
this.code = code;
this.details = details;
}
static fromResponse(
status: number,
body: any,
message?: string
): SDKError {
const errorMsg = message || body?.error || `HTTP ${status}`;
const code = body?.code || `HTTP_${status}`;
return new SDKError(errorMsg, status, code, body);
}
toJSON() {
return {
name: this.name,
message: this.message,
httpStatus: this.httpStatus,
code: this.code,
details: this.details,
};
}
}

82
src/index.ts Normal file
View File

@ -0,0 +1,82 @@
import { HttpClient, HttpClientConfig } from "./core/http";
import { AuthClient } from "./auth/client";
import { DBClient } from "./db/client";
import { PubSubClient } from "./pubsub/client";
import { NetworkClient } from "./network/client";
import { WSClientConfig } from "./core/ws";
import { StorageAdapter, MemoryStorage, LocalStorageAdapter } from "./auth/types";
export interface ClientConfig extends Omit<HttpClientConfig, "fetch"> {
apiKey?: string;
jwt?: string;
storage?: StorageAdapter;
wsConfig?: Partial<WSClientConfig>;
fetch?: typeof fetch;
}
export interface Client {
auth: AuthClient;
db: DBClient;
pubsub: PubSubClient;
network: NetworkClient;
}
export function createClient(config: ClientConfig): Client {
const httpClient = new HttpClient({
baseURL: config.baseURL,
timeout: config.timeout,
maxRetries: config.maxRetries,
retryDelayMs: config.retryDelayMs,
fetch: config.fetch,
});
const auth = new AuthClient({
httpClient,
storage: config.storage,
apiKey: config.apiKey,
jwt: config.jwt,
});
// Derive WebSocket URL from baseURL if not explicitly provided
const wsURL = config.wsConfig?.wsURL ??
config.baseURL.replace(/^http/, 'ws').replace(/\/$/, '');
const db = new DBClient(httpClient);
const pubsub = new PubSubClient(httpClient, {
...config.wsConfig,
wsURL,
});
const network = new NetworkClient(httpClient);
return {
auth,
db,
pubsub,
network,
};
}
// Re-exports
export { HttpClient } from "./core/http";
export { WSClient } from "./core/ws";
export { AuthClient } from "./auth/client";
export { DBClient } from "./db/client";
export { QueryBuilder } from "./db/qb";
export { Repository } from "./db/repository";
export { PubSubClient, Subscription } from "./pubsub/client";
export { NetworkClient } from "./network/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";
export type { PeerInfo, NetworkStatus } from "./network/client";

65
src/network/client.ts Normal file
View File

@ -0,0 +1,65 @@
import { HttpClient } from "../core/http";
export interface PeerInfo {
id: string;
addresses: string[];
lastSeen?: string;
}
export interface NetworkStatus {
healthy: boolean;
peers: number;
uptime?: number;
}
export class NetworkClient {
private httpClient: HttpClient;
constructor(httpClient: HttpClient) {
this.httpClient = httpClient;
}
/**
* Check gateway health.
*/
async health(): Promise<boolean> {
try {
await this.httpClient.get("/v1/health");
return true;
} catch {
return false;
}
}
/**
* Get network status.
*/
async status(): Promise<NetworkStatus> {
const response = await this.httpClient.get<NetworkStatus>("/v1/status");
return response;
}
/**
* Get connected peers.
*/
async peers(): Promise<PeerInfo[]> {
const response = await this.httpClient.get<{ peers: PeerInfo[] }>(
"/v1/network/peers"
);
return response.peers || [];
}
/**
* Connect to a peer.
*/
async connect(peerAddr: string): Promise<void> {
await this.httpClient.post("/v1/network/connect", { peer_addr: peerAddr });
}
/**
* Disconnect from a peer.
*/
async disconnect(peerId: string): Promise<void> {
await this.httpClient.post("/v1/network/disconnect", { peer_id: peerId });
}
}

142
src/pubsub/client.ts Normal file
View File

@ -0,0 +1,142 @@
import { HttpClient } from "../core/http";
import { WSClient, WSClientConfig } from "../core/ws";
export interface Message {
data: string;
topic: string;
timestamp?: number;
}
export type MessageHandler = (message: Message) => void;
export type ErrorHandler = (error: Error) => void;
export type CloseHandler = () => void;
export class PubSubClient {
private httpClient: HttpClient;
private wsConfig: Partial<WSClientConfig>;
constructor(httpClient: HttpClient, wsConfig: Partial<WSClientConfig> = {}) {
this.httpClient = httpClient;
this.wsConfig = wsConfig;
}
/**
* Publish a message to a topic.
*/
async publish(topic: string, data: string | Uint8Array): Promise<void> {
const dataBase64 =
typeof data === "string" ? Buffer.from(data).toString("base64") : Buffer.from(data).toString("base64");
await this.httpClient.post("/v1/pubsub/publish", {
topic,
data_base64: dataBase64,
});
}
/**
* List active topics in the current namespace.
*/
async topics(): Promise<string[]> {
const response = await this.httpClient.get<{ topics: string[] }>(
"/v1/pubsub/topics"
);
return response.topics || [];
}
/**
* Subscribe to a topic via WebSocket.
* Returns a subscription object with event handlers.
*/
async subscribe(
topic: string,
handlers: {
onMessage?: MessageHandler;
onError?: ErrorHandler;
onClose?: CloseHandler;
} = {}
): Promise<Subscription> {
const wsUrl = new URL(this.wsConfig.wsURL || "ws://localhost:6001");
wsUrl.pathname = "/v1/pubsub/ws";
wsUrl.searchParams.set("topic", topic);
const wsClient = new WSClient({
...this.wsConfig,
wsURL: wsUrl.toString(),
authToken: this.httpClient.getToken(),
});
const subscription = new Subscription(wsClient, topic);
if (handlers.onMessage) {
subscription.onMessage(handlers.onMessage);
}
if (handlers.onError) {
subscription.onError(handlers.onError);
}
if (handlers.onClose) {
subscription.onClose(handlers.onClose);
}
await wsClient.connect();
return subscription;
}
}
export class Subscription {
private wsClient: WSClient;
private topic: string;
private messageHandlers: Set<MessageHandler> = new Set();
private errorHandlers: Set<ErrorHandler> = new Set();
private closeHandlers: Set<CloseHandler> = new Set();
constructor(wsClient: WSClient, topic: string) {
this.wsClient = wsClient;
this.topic = topic;
this.wsClient.onMessage((data) => {
try {
const message: Message = {
topic: this.topic,
data: data,
timestamp: Date.now(),
};
this.messageHandlers.forEach((handler) => handler(message));
} catch (error) {
this.errorHandlers.forEach((handler) =>
handler(error instanceof Error ? error : new Error(String(error)))
);
}
});
this.wsClient.onError((error) => {
this.errorHandlers.forEach((handler) => handler(error));
});
this.wsClient.onClose(() => {
this.closeHandlers.forEach((handler) => handler());
});
}
onMessage(handler: MessageHandler) {
this.messageHandlers.add(handler);
return () => this.messageHandlers.delete(handler);
}
onError(handler: ErrorHandler) {
this.errorHandlers.add(handler);
return () => this.errorHandlers.delete(handler);
}
onClose(handler: CloseHandler) {
this.closeHandlers.add(handler);
return () => this.closeHandlers.delete(handler);
}
close() {
this.wsClient.close();
}
isConnected(): boolean {
return this.wsClient.isConnected();
}
}

39
tests/e2e/auth.test.ts Normal file
View File

@ -0,0 +1,39 @@
import { describe, it, expect, beforeAll } from "vitest";
import { createTestClient, skipIfNoGateway } from "./setup";
describe("Auth", () => {
beforeAll(() => {
if (skipIfNoGateway()) {
console.log("Skipping auth tests");
}
});
it("should get whoami", async () => {
const client = await createTestClient();
const whoami = await client.auth.whoami();
expect(whoami).toBeDefined();
expect(whoami.authenticated).toBe(true);
});
it("should switch API key and JWT", async () => {
const client = await createTestClient();
// Set API key
const apiKey = process.env.GATEWAY_API_KEY;
if (apiKey) {
client.auth.setApiKey(apiKey);
expect(client.auth.getToken()).toBe(apiKey);
}
// Set JWT (even if invalid, should update the token)
const testJwt = "test-jwt-token";
client.auth.setJwt(testJwt);
expect(client.auth.getToken()).toBe(testJwt);
});
it("should handle logout", async () => {
const client = await createTestClient();
await client.auth.logout();
expect(client.auth.getToken()).toBeUndefined();
});
});

149
tests/e2e/db.test.ts Normal file
View File

@ -0,0 +1,149 @@
import { describe, it, expect, beforeEach } from "vitest";
import { createTestClient, skipIfNoGateway, generateTableName } from "./setup";
describe("Database", () => {
if (skipIfNoGateway()) {
console.log("Skipping database tests");
}
let tableName: string;
beforeEach(() => {
tableName = generateTableName();
});
it("should create a table", async () => {
const client = await createTestClient();
await client.db.createTable(
`CREATE TABLE ${tableName} (id INTEGER PRIMARY KEY, name TEXT, email TEXT)`
);
// Verify by querying schema
const schema = await client.db.getSchema();
expect(schema).toBeDefined();
});
it("should insert and query data", async () => {
const client = await createTestClient();
// Create table
await client.db.createTable(
`CREATE TABLE ${tableName} (id INTEGER PRIMARY KEY, name TEXT, email TEXT)`
);
// Insert data
const result = await client.db.exec(
`INSERT INTO ${tableName} (name, email) VALUES (?, ?)`,
["Alice", "alice@example.com"]
);
expect(result.rows_affected).toBeGreaterThan(0);
// Query data
const rows = await client.db.query(
`SELECT * FROM ${tableName} WHERE email = ?`,
["alice@example.com"]
);
expect(rows).toHaveLength(1);
expect(rows[0].name).toBe("Alice");
});
it("should use find() and findOne()", async () => {
const client = await createTestClient();
// Create table
await client.db.createTable(
`CREATE TABLE ${tableName} (id INTEGER PRIMARY KEY, name TEXT, email TEXT)`
);
// Insert data
await client.db.exec(
`INSERT INTO ${tableName} (name, email) VALUES (?, ?)`,
["Bob", "bob@example.com"]
);
// Find one
const bob = await client.db.findOne(tableName, {
email: "bob@example.com",
});
expect(bob).toBeDefined();
expect(bob?.name).toBe("Bob");
// Find all
const all = await client.db.find(tableName, {});
expect(all.length).toBeGreaterThan(0);
});
it("should use QueryBuilder", async () => {
const client = await createTestClient();
// Create table
await client.db.createTable(
`CREATE TABLE ${tableName} (id INTEGER PRIMARY KEY, name TEXT, email TEXT, active INTEGER)`
);
// Insert test data
await client.db.exec(
`INSERT INTO ${tableName} (name, email, active) VALUES (?, ?, ?)`,
["Charlie", "charlie@example.com", 1]
);
await client.db.exec(
`INSERT INTO ${tableName} (name, email, active) VALUES (?, ?, ?)`,
["Diana", "diana@example.com", 0]
);
// Query with builder
const qb = client.db.createQueryBuilder(tableName);
const active = await qb
.where("active = ?", [1])
.orderBy("name")
.getMany();
expect(active.length).toBeGreaterThan(0);
expect(active[0].name).toBe("Charlie");
});
it("should use Repository for save/remove", async () => {
const client = await createTestClient();
// Create table
await client.db.createTable(
`CREATE TABLE ${tableName} (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, email TEXT)`
);
const repo = client.db.repository<{
id?: number;
name: string;
email: string;
}>(tableName);
// Save (insert)
const entity = { name: "Eve", email: "eve@example.com" };
await repo.save(entity);
expect(entity.id).toBeDefined();
// Find one
const found = await repo.findOne({ email: "eve@example.com" });
expect(found).toBeDefined();
expect(found?.name).toBe("Eve");
// Update
if (found) {
found.name = "Eve Updated";
await repo.save(found);
}
// Verify update
const updated = await repo.findOne({ id: entity.id });
expect(updated?.name).toBe("Eve Updated");
// Remove
if (updated) {
await repo.remove(updated);
}
// Verify deletion
const deleted = await repo.findOne({ id: entity.id });
expect(deleted).toBeNull();
});
});

30
tests/e2e/network.test.ts Normal file
View File

@ -0,0 +1,30 @@
import { describe, it, expect, beforeAll } from "vitest";
import { createTestClient, skipIfNoGateway } from "./setup";
describe("Network", () => {
beforeAll(() => {
if (skipIfNoGateway()) {
console.log("Skipping network tests");
}
});
it("should check health", async () => {
const client = await createTestClient();
const healthy = await client.network.health();
expect(typeof healthy).toBe("boolean");
});
it("should get network status", async () => {
const client = await createTestClient();
const status = await client.network.status();
expect(status).toBeDefined();
expect(typeof status.healthy).toBe("boolean");
expect(typeof status.peers).toBe("number");
});
it("should list peers", async () => {
const client = await createTestClient();
const peers = await client.network.peers();
expect(Array.isArray(peers)).toBe(true);
});
});

91
tests/e2e/pubsub.test.ts Normal file
View File

@ -0,0 +1,91 @@
import { describe, it, expect, beforeEach } from "vitest";
import { createTestClient, skipIfNoGateway, delay } from "./setup";
describe("PubSub", () => {
if (skipIfNoGateway()) {
console.log("Skipping PubSub tests");
}
let topicName: string;
beforeEach(() => {
topicName = `test_topic_${Date.now()}_${Math.random().toString(36).substring(7)}`;
});
it("should get topics list", async () => {
const client = await createTestClient();
const topics = await client.pubsub.topics();
expect(Array.isArray(topics)).toBe(true);
});
it("should publish a message", async () => {
const client = await createTestClient();
const testMessage = "Hello from test";
// Should not throw
await client.pubsub.publish(topicName, testMessage);
expect(true).toBe(true);
});
it("should subscribe and receive published message", async () => {
const client = await createTestClient();
const testMessage = "Test message";
let receivedMessage: any = null;
// Create subscription with handlers
const subscription = await client.pubsub.subscribe(topicName, {
onMessage: (msg) => {
receivedMessage = msg;
},
onError: (err) => {
console.error("Subscription error:", err);
},
});
// Give subscription a moment to establish
await delay(500);
// Publish message
await client.pubsub.publish(topicName, testMessage);
// Wait for message to arrive
await delay(1000);
// Should have received the message
expect(receivedMessage).toBeDefined();
expect(receivedMessage?.topic).toBe(topicName);
// Cleanup
subscription.close();
});
it("should handle subscription events", async () => {
const client = await createTestClient();
const events: string[] = [];
const subscription = await client.pubsub.subscribe(topicName, {
onMessage: () => {
events.push("message");
},
onError: (err) => {
events.push("error");
},
onClose: () => {
events.push("close");
},
});
// Publish a message
await delay(300);
await client.pubsub.publish(topicName, "test");
// Wait for event
await delay(500);
// Close and check for close event
subscription.close();
await delay(300);
expect(events.length).toBeGreaterThanOrEqual(0);
});
});

54
tests/e2e/setup.ts Normal file
View File

@ -0,0 +1,54 @@
import { createClient } from "../../src/index";
import { SDKError } from "../../src/errors";
export function getGatewayUrl(): string {
return process.env.GATEWAY_BASE_URL || "http://localhost:6001";
}
export function getApiKey(): string | undefined {
return process.env.GATEWAY_API_KEY;
}
export function getJwt(): string | undefined {
return process.env.GATEWAY_JWT;
}
export function skipIfNoGateway() {
const url = getGatewayUrl();
const apiKey = getApiKey();
if (!apiKey) {
console.log("Skipping: GATEWAY_API_KEY not set");
return true;
}
return false;
}
export async function createTestClient() {
const client = createClient({
baseURL: getGatewayUrl(),
apiKey: getApiKey(),
jwt: getJwt(),
});
return client;
}
export function generateTableName(): string {
return `test_${Date.now()}_${Math.random().toString(36).substring(7)}`;
}
export async function delay(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export async function isGatewayReady(): Promise<boolean> {
try {
const client = await createTestClient();
const healthy = await client.network.health();
return healthy;
} catch {
return false;
}
}

75
tests/e2e/tx.test.ts Normal file
View File

@ -0,0 +1,75 @@
import { describe, it, expect, beforeEach } from "vitest";
import { createTestClient, skipIfNoGateway, generateTableName } from "./setup";
describe("Transactions", () => {
if (skipIfNoGateway()) {
console.log("Skipping transaction tests");
}
let tableName: string;
beforeEach(() => {
tableName = generateTableName();
});
it("should execute transaction with multiple ops", async () => {
const client = await createTestClient();
// Create table
await client.db.createTable(
`CREATE TABLE ${tableName} (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, balance INTEGER)`
);
// Execute transaction with multiple operations
const results = await client.db.transaction([
{
kind: "exec",
sql: `INSERT INTO ${tableName} (name, balance) VALUES (?, ?)`,
args: ["User A", 100],
},
{
kind: "exec",
sql: `INSERT INTO ${tableName} (name, balance) VALUES (?, ?)`,
args: ["User B", 200],
},
{
kind: "query",
sql: `SELECT COUNT(*) as count FROM ${tableName}`,
args: [],
},
]);
expect(results).toBeDefined();
});
it("should support query inside transaction", async () => {
const client = await createTestClient();
// Create table
await client.db.createTable(
`CREATE TABLE ${tableName} (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, value INTEGER)`
);
// Pre-insert data
await client.db.exec(
`INSERT INTO ${tableName} (name, value) VALUES (?, ?)`,
["item1", 10]
);
// Transaction with insert and query
const results = await client.db.transaction([
{
kind: "exec",
sql: `INSERT INTO ${tableName} (name, value) VALUES (?, ?)`,
args: ["item2", 20],
},
{
kind: "query",
sql: `SELECT SUM(value) as total FROM ${tableName}`,
args: [],
},
]);
expect(results).toBeDefined();
});
});

22
tsconfig.json Normal file
View File

@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src",
"resolveJsonModule": true,
"isolatedModules": true,
"types": ["node", "vitest/globals"]
},
"include": ["src"],
"exclude": ["node_modules", "dist", "tests"]
}

11
tsup.config.ts Normal file
View File

@ -0,0 +1,11 @@
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["src/index.ts"],
format: ["esm"],
dts: true,
sourcemap: true,
clean: true,
shims: true,
outDir: "dist",
});

10
vitest.config.ts Normal file
View File

@ -0,0 +1,10 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: "node",
include: ["tests/**/*.test.ts"],
testTimeout: 30000,
},
});