mirror of
https://github.com/DeBrosOfficial/network-ts-sdk.git
synced 2025-12-10 09:38:50 +00:00
Init Network Typescript SDK
This commit is contained in:
commit
ddec9f6733
4
.env.example
Normal file
4
.env.example
Normal 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
18
.gitignore
vendored
Normal 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
2
.npmrc
Normal file
@ -0,0 +1,2 @@
|
||||
@network:registry=https://npm.pkg.github.com
|
||||
//npm.pkg.github.com/:_authToken=${NPM_TOKEN}
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal 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
170
QUICKSTART.md
Normal 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
329
README.md
Normal 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
42
package.json
Normal 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
2845
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
97
src/auth/client.ts
Normal file
97
src/auth/client.ts
Normal 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
62
src/auth/types.ts
Normal 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
158
src/core/http.ts
Normal 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
182
src/core/ws.ts
Normal 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
126
src/db/client.ts
Normal 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
111
src/db/qb.ts
Normal 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
124
src/db/repository.ts
Normal 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
67
src/db/types.ts
Normal 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
38
src/errors.ts
Normal 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
82
src/index.ts
Normal 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
65
src/network/client.ts
Normal 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
142
src/pubsub/client.ts
Normal 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
39
tests/e2e/auth.test.ts
Normal 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
149
tests/e2e/db.test.ts
Normal 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
30
tests/e2e/network.test.ts
Normal 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
91
tests/e2e/pubsub.test.ts
Normal 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
54
tests/e2e/setup.ts
Normal 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
75
tests/e2e/tx.test.ts
Normal 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
22
tsconfig.json
Normal 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
11
tsup.config.ts
Normal 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
10
vitest.config.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "node",
|
||||
include: ["tests/**/*.test.ts"],
|
||||
testTimeout: 30000,
|
||||
},
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user