/** * Tencent Cloud VectorDB HTTP Client. * * Thin wrapper around the VectorDB HTTP API. Handles authentication, timeouts, * retries (5xx / timeout), and error normalization. * * API docs: https://cloud.tencent.com/document/product/1709 */ import fs from "node:fs"; import { request as undiciRequest, Agent as UndiciAgent } from "undici"; import type { Dispatcher } from "undici"; import type { StoreLogger } from "./types.js"; // ============================ // Types // ============================ export interface TcvdbClientConfig { /** Instance URL (e.g. "http://10.0.1.1:80") */ url: string; /** Account name (default: "root") */ username: string; /** API Key */ apiKey: string; /** Database name */ database: string; /** Request timeout in ms (default: 10000) */ timeout: number; /** Path to CA certificate PEM file (for HTTPS connections) */ caPemPath?: string; } /** Standard VectorDB API response envelope. */ interface ApiResponse { code: number; msg: string; [key: string]: unknown; } /** Search/hybridSearch response shape. */ export interface SearchResponse { documents: Array>>; } /** Query response shape. */ export interface QueryResponse { documents: Array>; count?: number; } /** Collection info from describeCollection. */ export interface CollectionInfo { collection: string; database: string; documentCount?: number; embedding?: { field: string; vectorField: string; model: string; }; indexes?: Array>; [key: string]: unknown; } export class TcvdbApiError extends Error { readonly apiCode: number; constructor(path: string, code: number, msg: string) { super(`VectorDB ${path}: code=${code}, msg=${msg}`); this.name = "TcvdbApiError"; this.apiCode = code; } } // ============================ // Client // ============================ const TAG = "[memory-tdai][tcvdb-client]"; const MAX_RETRIES = 2; export class TcvdbClient { private readonly baseUrl: string; private readonly authHeader: string; private readonly database: string; private readonly timeout: number; private readonly logger?: StoreLogger; /** undici dispatcher for HTTPS + custom CA. */ private readonly dispatcher?: Dispatcher; constructor(config: TcvdbClientConfig, logger?: StoreLogger) { this.baseUrl = config.url.replace(/\/+$/, ""); this.authHeader = `Bearer account=${config.username}&api_key=${config.apiKey}`; this.database = config.database; this.timeout = config.timeout; this.logger = logger; // Log connection info at construction time. this.logger?.debug?.(`${TAG} url=${this.baseUrl} db=${this.database} timeout=${this.timeout}${this.baseUrl.startsWith("https://") ? ` https=true caPemPath=${config.caPemPath ?? "(none)"}` : ""}`); // For HTTPS with a custom CA certificate, create a dedicated undici Agent. // We use undici.request() instead of global fetch because fetch's // `dispatcher` option is unreliable across Node versions. if (this.baseUrl.startsWith("https://") && config.caPemPath) { try { const ca = fs.readFileSync(config.caPemPath, "utf-8"); this.dispatcher = new UndiciAgent({ connect: { ca } }); this.logger?.debug?.(`${TAG} HTTPS enabled with CA from ${config.caPemPath}`); } catch (err) { this.logger?.error(`${TAG} Failed to load CA PEM from ${config.caPemPath}: ${err instanceof Error ? err.message : String(err)}`); } } } // ── Generic request ───────────────────────────────────── /** * Send a POST request to VectorDB API. * Handles auth, timeout, retries (5xx/timeout), and error unwrapping. */ async request(path: string, body: Record): Promise { let lastError: Error | undefined; const t0 = performance.now(); for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { const tAttempt = performance.now(); try { this.logger?.debug?.(`${TAG} → ${path} attempt=${attempt} body=${JSON.stringify(body).slice(0, 500)}`); const { statusCode, body: respBody } = await undiciRequest(`${this.baseUrl}${path}`, { method: "POST", headers: { "Content-Type": "application/json", "Authorization": this.authHeader, }, body: JSON.stringify(body), signal: AbortSignal.timeout(this.timeout), ...(this.dispatcher ? { dispatcher: this.dispatcher } : {}), }); const text = await respBody.text(); const json = JSON.parse(text) as ApiResponse; const attemptMs = Math.round(performance.now() - tAttempt); this.logger?.debug?.(`${TAG} ← ${path} status=${statusCode} code=${json.code} attemptMs=${attemptMs} attempt=${attempt}`); if (json.code !== 0) { const err = new TcvdbApiError(path, json.code, json.msg); if (statusCode !== undefined && statusCode >= 400 && statusCode < 500) throw err; lastError = err; continue; } // Always log completion at info level (one line per request) const totalMs = Math.round(performance.now() - t0); this.logger?.info(`${TAG} ${path} ${totalMs}ms${attempt > 0 ? ` (${attempt + 1} attempts)` : ""}`); return json as unknown as T; } catch (err) { const attemptMs = Math.round(performance.now() - tAttempt); if (err instanceof TcvdbApiError && err.apiCode !== 0) throw err; lastError = err instanceof Error ? err : new Error(String(err)); if (attempt < MAX_RETRIES) { const delay = 500 * (attempt + 1); this.logger?.debug?.(`${TAG} ${path} retry ${attempt + 1}/${MAX_RETRIES} in ${delay}ms (lastAttemptMs=${attemptMs}, error=${lastError.message})`); await new Promise((r) => setTimeout(r, delay)); } } } const totalMs = Math.round(performance.now() - t0); this.logger?.debug?.(`${TAG} ✗ ${path} totalMs=${totalMs} attempts=${MAX_RETRIES + 1} error=${lastError?.message}`); throw lastError ?? new Error(`${TAG} ${path} failed after retries`); } // ── Database operations ───────────────────────────────── async createDatabase(dbName?: string): Promise { const name = dbName ?? this.database; // SDK pattern: list first, create only if not found const listResp = await this.request<{ databases: string[] }>("/database/list", {}); const exists = (listResp.databases ?? []).includes(name); if (exists) { this.logger?.debug?.(`${TAG} Database already exists: ${name}`); return false; } await this.request("/database/create", { database: name }); this.logger?.info(`${TAG} Database created: ${name}`); return true; } // ── Collection operations ─────────────────────────────── async createCollection(params: Record): Promise { const name = String(params.collection ?? ""); // SDK pattern: try describe first, create only if not found (code 15302) try { await this.describeCollection(name); this.logger?.debug?.(`${TAG} Collection already exists: ${name}`); return; } catch (err) { if (!(err instanceof TcvdbApiError && err.apiCode === 15302)) { throw err; // unexpected error } // 15302 = collection not found → proceed to create } try { await this.request("/collection/create", { database: this.database, ...params, }); this.logger?.info(`${TAG} Collection created: ${name}`); } catch (err) { // 15202 = collection already exists — race between describe and create. // Semantically identical to "describe found it", so treat as success. if (err instanceof TcvdbApiError && err.apiCode === 15202) { this.logger?.debug?.(`${TAG} Collection already exists (race): ${name}`); return; } throw err; } } async describeCollection(collection: string): Promise { const resp = await this.request<{ collection: CollectionInfo }>("/collection/describe", { database: this.database, collection, }); return resp.collection; } // ── Document operations ───────────────────────────────── async upsert(collection: string, documents: Record[]): Promise { await this.request("/document/upsert", { database: this.database, collection, buildIndex: true, documents, }); } async search(collection: string, searchParams: Record): Promise { return this.request("/document/search", { database: this.database, collection, readConsistency: "strongConsistency", search: searchParams, }); } async hybridSearch(collection: string, searchParams: Record): Promise { return this.request("/document/hybridSearch", { database: this.database, collection, readConsistency: "strongConsistency", search: searchParams, }); } async query(collection: string, queryParams: Record): Promise { return this.request("/document/query", { database: this.database, collection, readConsistency: "strongConsistency", query: queryParams, }); } async deleteDoc(collection: string, params: Record): Promise { await this.request("/document/delete", { database: this.database, collection, ...params, }); } /** * Count documents matching an optional filter. * Uses the dedicated /document/count endpoint. */ async count(collection: string, filter?: string): Promise { const query: Record = {}; if (filter) query.filter = filter; const resp = await this.request<{ count: number }>("/document/count", { database: this.database, collection, readConsistency: "strongConsistency", query, }); return resp.count ?? 0; } // ── Convenience getters ───────────────────────────────── getDatabase(): string { return this.database; } }