Devnet had TURN running on ZERO nodes for namespace anchat-test after a node replacement, with every node reporting failed=0 and six hours of logs containing no matching lines. Four defects, one outage. #173 (root cause) — namespace_cluster_nodes accumulated rows for permanently dead nodes; removeClusterNodeAssignment existed but was never called, and RepairCluster is add-only. With 4 members (2 corpses) the WebRTC reconciler computed 2*live > members => 2*2 > 4 => false: a permanent 50/50 deadlock that could never resolve. Adds pruneStaleClusterNodes, wired into RepairCluster and the 60s reconcile loop, keyed off dns_nodes staleness. Also explains why the ring health monitor never fired: startDNSHeartbeat flips a silent node to inactive at 120s, but getRingNeighbors only probes active nodes, so the node leaves every observer's set before the monitor's own ~120s threshold and its miss count is discarded. The DNS sweep almost always wins that race. The prune is independent of it. Second bug found: an unpruned corpse made RepairCluster count it as active, so a replacement was never triggered. #170 — viable and live member sets came from two separate rqlite queries, so live ⊆ viable was incidental, not structural; combined with webrtcReconcileQuorumOK(live, 0) returning true, an empty viable set passed quorum and deallocated every role while allocating nothing back. Now one merged query split in Go, plus an explicit empty-set guard. #171 — regression in the prior fix: past the grace window both numerator and denominator derive from the same liveness signal, so a lone node always had "quorum" and would strip every other node's roles onto itself. Reachable via our own serial rolling-restart runbook. Adds webrtcReconcileMajorityHeld (viable >= (raw+1)/2) and a 5m startup grace. #172 — shortfall log moved after the plan and conditioned on the viable set, stale invariant comments corrected, grace-boundary and end-to-end tests added. Verified on devnet 0.122.100: TURN 0 -> 2 nodes, SFU 1 -> 3, every allocation on a live node, both corpses gone from namespace_cluster_nodes. 183 tests, go vet and -race clean. Testnet untouched. Build note: vault/build.zig.zon declares minimum_zig_version 0.15.2; Zig 0.16 removed GeneralPurposeAllocator and process.argsAlloc. Build with zig@0.15. Co-Authored-By: Claude <noreply@anthropic.com>
@debros/orama - TypeScript SDK for Orama Network
A modern, isomorphic TypeScript SDK for the Orama 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
npm install @debros/orama
Quick Start
Initialize the Client
import { createClient } from "@debros/orama";
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
await client.db.createTable(
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, email TEXT)"
);
Insert Data
const result = await client.db.exec(
"INSERT INTO users (name, email) VALUES (?, ?)",
["Alice", "alice@example.com"]
);
console.log(result.last_insert_id);
Query Data
const users = await client.db.query("SELECT * FROM users WHERE email = ?", [
"alice@example.com",
]);
Using QueryBuilder
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
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
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
The SDK provides a robust pub/sub client with:
- Multi-subscriber support: Multiple connections can subscribe to the same topic
- Namespace isolation: Topics are scoped to your authenticated namespace
- Server timestamps: Messages preserve server-side timestamps
- Binary-safe: Supports both string and binary (
Uint8Array) payloads - Strict envelope validation: Type-safe message parsing with error handling
Publish a Message
// Publish a string message
await client.pubsub.publish("notifications", "Hello, Network!");
// Publish binary data
const binaryData = new Uint8Array([1, 2, 3, 4]);
await client.pubsub.publish("binary-topic", binaryData);
Subscribe to Topics
const subscription = await client.pubsub.subscribe("notifications", {
onMessage: (msg) => {
console.log("Topic:", msg.topic);
console.log("Data:", msg.data);
console.log("Server timestamp:", new Date(msg.timestamp));
},
onError: (err) => {
console.error("Subscription error:", err);
},
onClose: () => {
console.log("Subscription closed");
},
});
// Later, close the subscription
subscription.close();
Message Interface:
interface PubSubMessage {
data: string; // Decoded message payload (string)
topic: string; // Topic name
timestamp: number; // Server timestamp in milliseconds
}
Debug Raw Envelopes
Not yet available. An onRaw callback for inspecting raw message envelopes before decoding is not implemented. SubscribeOptions currently supports onMessage, onError, onClose, and presence.
Multi-Subscriber Support
Multiple subscriptions to the same topic are supported. Each receives its own copy of messages:
// First subscriber
const sub1 = await client.pubsub.subscribe("events", {
onMessage: (msg) => console.log("Sub1:", msg.data),
});
// Second subscriber (both receive messages)
const sub2 = await client.pubsub.subscribe("events", {
onMessage: (msg) => console.log("Sub2:", msg.data),
});
// Unsubscribe independently
sub1.close(); // sub2 still active
sub2.close(); // fully unsubscribed
List Topics
const topics = await client.pubsub.topics();
console.log("Active topics:", topics);
Presence Support
The SDK supports real-time presence tracking, allowing you to see who is currently subscribed to a topic.
Subscribe with Presence
Enable presence by providing presence options in subscribe:
const subscription = await client.pubsub.subscribe("room.123", {
onMessage: (msg) => console.log("Message:", msg.data),
presence: {
enabled: true,
memberId: "user-alice",
meta: { displayName: "Alice", avatar: "URL" },
onJoin: (member) => {
console.log(`${member.memberId} joined at ${new Date(member.joinedAt)}`);
console.log("Meta:", member.meta);
},
onLeave: (member) => {
console.log(`${member.memberId} left`);
},
},
});
Get Presence for a Topic
Query current members without subscribing:
const presence = await client.pubsub.getPresence("room.123");
console.log(`Total members: ${presence.count}`);
presence.members.forEach((member) => {
console.log(`- ${member.memberId} (joined: ${new Date(member.joinedAt)})`);
});
Subscription Helpers
Get presence information from an active subscription:
if (subscription.hasPresence()) {
const members = await subscription.getPresence();
console.log("Current members:", members);
}
Authentication
Switch API Key
client.auth.setApiKey("ak_new_key:namespace");
Switch JWT
client.auth.setJwt("new_jwt_token");
Get Current Token
const token = client.auth.getToken(); // Returns API key or JWT
Get Authentication Info
const info = await client.auth.whoami();
console.log(info.authenticated, info.namespace);
Logout
await client.auth.logout();
Network Operations
Check Health
const healthy = await client.network.health();
Get Network Status
const status = await client.network.status();
console.log(status.node_id, status.connected, status.peer_count);
// NetworkStatus: { node_id, connected, peer_count, database_size, uptime }
List Peers
const peers = await client.network.peers();
peers.forEach((peer) => {
console.log(peer.id, peer.addresses);
});
Proxy Requests Through Anyone Network
Make anonymous HTTP requests through the Anyone network:
// Simple GET request
const response = await client.network.proxyAnon({
url: "https://api.example.com/data",
method: "GET",
headers: {
Accept: "application/json",
},
});
console.log(response.status_code); // 200
console.log(response.body); // Response data as string
console.log(response.headers); // Response headers
// POST request with body
const postResponse = await client.network.proxyAnon({
url: "https://api.example.com/submit",
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ key: "value" }),
});
// Parse JSON response
const data = JSON.parse(postResponse.body);
Note: The proxy endpoint requires authentication (API key or JWT) and only works when the Anyone relay is running on the gateway server.
Configuration
ClientConfig
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)
debug?: boolean; // Enable debug logging with full SQL queries (default: false)
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:
import { createClient, LocalStorageAdapter } from "@debros/orama";
const client = createClient({
baseURL: "http://localhost:6001",
storage: new LocalStorageAdapter(),
apiKey: "ak_your_key:namespace",
});
Cache Operations
The SDK provides a distributed cache client backed by Olric. Data is organized into distributed maps (dmaps).
Put a Value
// Put with optional TTL
await client.cache.put("sessions", "user:alice", { role: "admin" }, "1h");
Get a Value
// Returns null on cache miss (not an error)
const result = await client.cache.get("sessions", "user:alice");
if (result) {
console.log(result.value); // { role: "admin" }
}
Delete a Value
await client.cache.delete("sessions", "user:alice");
Multi-Get
const results = await client.cache.multiGet("sessions", [
"user:alice",
"user:bob",
]);
// Returns Map<string, any | null> — null for misses
results.forEach((value, key) => {
console.log(key, value);
});
Scan Keys
// Scan all keys in a dmap, optionally matching a regex
const scan = await client.cache.scan("sessions", "user:.*");
console.log(scan.keys); // ["user:alice", "user:bob"]
console.log(scan.count); // 2
Health Check
const health = await client.cache.health();
console.log(health.status); // "ok"
Storage (IPFS)
Upload, pin, and retrieve files from decentralized IPFS storage.
Upload a File
// Browser
const fileInput = document.querySelector('input[type="file"]');
const file = fileInput.files[0];
const result = await client.storage.upload(file, file.name);
console.log(result.cid); // "Qm..."
// Node.js
import { readFileSync } from "fs";
const buffer = readFileSync("image.jpg");
const result = await client.storage.upload(buffer, "image.jpg", { pin: true });
Retrieve Content
// Get as ReadableStream
const stream = await client.storage.get(cid);
const reader = stream.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
// Process chunk
}
// Get full Response (for headers like content-length)
const response = await client.storage.getBinary(cid);
const contentLength = response.headers.get("content-length");
Pin / Unpin / Status
// Pin an existing CID
await client.storage.pin("QmExampleCid", "my-file");
// Check pin status
const status = await client.storage.status("QmExampleCid");
console.log(status.status); // "pinned", "pinning", "queued", "unpinned", "error"
// Unpin
await client.storage.unpin("QmExampleCid");
Serverless Functions (WASM)
Invoke WebAssembly serverless functions deployed on the network.
// Configure functions namespace
const client = createClient({
baseURL: "http://localhost:6001",
apiKey: "ak_your_key:namespace",
functionsConfig: {
namespace: "my-namespace",
},
});
// Invoke a function with typed input/output
interface PushInput {
token: string;
message: string;
}
interface PushOutput {
success: boolean;
messageId: string;
}
const result = await client.functions.invoke<PushInput, PushOutput>(
"send-push",
{ token: "device-token", message: "Hello!" }
);
console.log(result.messageId);
Vault (Distributed Secrets)
The vault client provides Shamir-split secret storage across guardian nodes. Secrets are split into shares, distributed to guardians, and reconstructed only when enough shares are collected (quorum).
const client = createClient({
baseURL: "http://localhost:6001",
apiKey: "ak_your_key:namespace",
vaultConfig: {
guardians: [
{ address: "10.0.0.1", port: 8443 },
{ address: "10.0.0.2", port: 8443 },
{ address: "10.0.0.3", port: 8443 },
],
hmacKey: yourHmacKey, // Uint8Array — HMAC key for guardian authentication
identityHex: "your-identity-hex", // 64-char hex identity hash
},
});
// Store a secret (Shamir-split across guardians)
const data = new TextEncoder().encode("my-secret-data");
const storeResult = await client.vault.store("api-key", data, 1);
console.log(storeResult.quorumMet); // true if enough guardians ACKed
// Retrieve and reconstruct a secret
const retrieved = await client.vault.retrieve("api-key");
console.log(new TextDecoder().decode(retrieved.data)); // "my-secret-data"
// List all secrets for this identity
const secrets = await client.vault.list();
console.log(secrets.secrets);
// Delete a secret from all guardians
await client.vault.delete("api-key");
Wallet-Based Authentication
For wallet-based auth (challenge-response flow):
// 1. Request a challenge nonce for your wallet
const challenge = await client.auth.challenge({ wallet: "0xYourWallet" });
// 2. Sign the nonce with your wallet (external)
const signature = await wallet.signMessage(challenge.nonce);
// 3. Verify signature and get JWT (persisted on the client automatically)
const session = await client.auth.verify({
wallet: "0xYourWallet",
nonce: challenge.nonce,
signature,
chain_type: "ETH", // or "SOL"
});
console.log(session.access_token);
console.log(session.api_key); // API key for long-lived access, if issued
// Alternatively, request an API key directly. Nonces are single-use,
// so sign a fresh challenge:
const fresh = await client.auth.challenge({ wallet: "0xYourWallet" });
const apiKey = await client.auth.getApiKey({
wallet: "0xYourWallet",
nonce: fresh.nonce,
signature: await wallet.signMessage(fresh.nonce),
});
console.log(apiKey.api_key);
Error Handling
The SDK throws SDKError for all errors:
import { SDKError } from "@debros/orama";
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:
// Browser example
import { createClient } from "@debros/orama";
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:
# 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
npm run build
Output goes to dist/ with ESM and type declarations.
Development
npm run dev # Watch mode
npm run typecheck # Type checking
npm run lint # Linting
License
MIT
Support
For issues, questions, or contributions, please open an issue on GitHub or visit DeBros Network Documentation.