From 6b3309bf88d3f4c3df09d1bf31794c6030235677 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Apr 2026 05:25:34 +0000 Subject: [PATCH 1/3] feat: add Pinecone vector database driver Closes #15 Full PineconeDriver implementation using @pinecone-database/pinecone SDK. Integrated across connection form, driver factory, MCP server, import service, tree view, and driver contract tests. - Schema: indexes as tables, namespaces as children with vector counts - Custom command syntax: QUERY, UPSERT, DELETE, STATS, LIST - Connection form: API key field, no host/port - Optional methods: getEstimatedRowCount, getTableStatistics - 12 unit tests for command parser, driver contract tests updated --- CHANGELOG.md | 3 + CLAUDE.md | 5 +- README.md | 1 + l10n/nls/package.nls.json | 2 +- package-lock.json | 10 + package.json | 10 + src/drivers/index.ts | 3 + src/drivers/pinecone.ts | 374 +++++++++++++++++++++++++ src/mcp-server/index.ts | 10 +- src/services/importService.ts | 2 + src/test/driverContract.test.ts | 16 +- src/test/pineconeParser.test.ts | 95 +++++++ src/types/connection.ts | 3 +- src/types/schema.ts | 3 +- src/views/connectionForm.ts | 9 + src/views/connectionTree.ts | 1 + src/webview/scripts/connection-form.js | 27 +- webpack.config.js | 2 + 18 files changed, 556 insertions(+), 20 deletions(-) create mode 100644 src/drivers/pinecone.ts create mode 100644 src/test/pineconeParser.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e2542f9..90ecac1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ All notable changes to Viewstor are documented here. Format based on [Keep a Cha ## [Unreleased] +### Added +- **Pinecone vector database driver** — browse indexes and namespaces, query vectors by similarity, upsert/delete vectors, view index statistics. Connection uses API key (no host/port). Custom command syntax: `QUERY vector=[...] topK=N`, `UPSERT`, `DELETE`, `STATS`, `LIST`. Read-only mode disables upsert/delete ([#15](https://github.com/Siyet/viewstor/issues/15)) + ### Fixed - **Pagination broken after running a custom query in table mode** — editing the SQL in the table view ran the query once and showed `Page 1/1`; clicking Next silently reverted to the original table. `_runCustomTableQuery` now accepts a `page` parameter, strips the trailing `LIMIT [OFFSET]`, re-applies server-side `LIMIT pageSize OFFSET page*pageSize`, and gets the exact row count via `SELECT COUNT(*) FROM () _sub`. Webview forwards the active custom query on every `changePage` / `changePageSize` so the host routes back to the same query instead of falling back to the default table fetch. User's explicit `LIMIT` is respected as a ceiling — e.g. `LIMIT 250` with `pageSize=100` yields exactly `100 + 100 + 50` rows across three pages, not `100 + 100 + 100`. When the count query fails (exotic SQL) pagination falls back to a "page full ⇒ probably more" heuristic. Manual ORDER BY in the SQL bar now syncs back to the header sort icons on Run. Destructive statements (VACUUM / INSERT / UPDATE / DELETE / DROP / …) are blocked at the host — only SELECT / WITH / EXPLAIN / SHOW / VALUES / TABLE are executed. Clearing the SQL bar and pressing Refresh re-populates the field with the default table SELECT so the baseline is always recoverable diff --git a/CLAUDE.md b/CLAUDE.md index 9dd1f92..dcfc9de 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ Guidance for Claude Code when working with this repository. ## What is Viewstor -VS Code extension for database management. Supports PostgreSQL, Redis, ClickHouse, SQLite. Free, open-source (AGPL-3.0) alternative to DBeaver/DataGrip. Follows [ZeroVer](https://0ver.org) — version 0.x until API is stable. +VS Code extension for database management. Supports PostgreSQL, Redis, ClickHouse, SQLite, Pinecone. Free, open-source (AGPL-3.0) alternative to DBeaver/DataGrip. Follows [ZeroVer](https://0ver.org) — version 0.x until API is stable. ## Commands @@ -33,7 +33,7 @@ Required methods: `connect`, `disconnect`, `ping`, `execute`, `getSchema`, `getT Optional: `getTableRowCount`, `getEstimatedRowCount` (pg_class.reltuples / system.tables), `getDDL`, `cancelQuery` (PG: pg_cancel_backend, CH: AbortController), `getCompletions` (structured: table/view/column/schema with parent), `getIndexedColumns` (pg_index query), `getTableObjects` (indexes, constraints, triggers, sequences — used by data diff), `getTableStatistics` (row count, sizes, vacuum info, scan counters — used by stats diff tab; PG uses `pg_table_size`/`pg_indexes_size` + `pg_stat_user_tables`, CH uses `system.tables` + `system.parts`, SQLite uses `COUNT(*)` + optional `dbstat` vtable). -Drivers: `postgres.ts` (pg), `redis.ts` (ioredis), `clickhouse.ts` (@clickhouse/client), `sqlite.ts` (better-sqlite3). +Drivers: `postgres.ts` (pg), `redis.ts` (ioredis), `clickhouse.ts` (@clickhouse/client), `sqlite.ts` (better-sqlite3), `pinecone.ts` (@pinecone-database/pinecone). ### Connections `src/connections/connectionManager.ts` — persists in VS Code `globalState` (keys: `viewstor.connections`, `viewstor.connectionFolders`). @@ -231,3 +231,4 @@ All commands support `databaseName` parameter for multi-DB connections. - ClickHouse execute uses `JSON` format (not `JSONEachRow`) to get column types from response metadata - SQLite: file-based connection (`config.database` = file path or `:memory:`), no host/port/auth. Uses `sqlite_master` + `PRAGMA table_info()` for schema. `getEstimatedRowCount()` falls back to exact `COUNT(*)`. WAL journal mode enabled on connect (skipped for readonly). Foreign keys always enabled. Connection form shows file picker instead of host/port fields. `inferTypeFromValue()` detects column types for computed expressions (COUNT→INTEGER, SUM→REAL). - SQLite native module: `better-sqlite3` requires different prebuilds for Node.js (tests) and Electron (Extension Host). `scripts/sqlite-rebuild.js` manages dual builds with `prebuild-install` (NOT `electron-rebuild` which is broken). Cache in `node_modules/.cache/sqlite-builds/` with `.meta` files. `npm run dev/build/watch` auto-restores Electron binary; `npm test` switches to Node.js binary. +- Pinecone: cloud vector database, API key auth (`config.password` = API key), no host/port. Uses `@pinecone-database/pinecone` SDK via lazy `require()`. Schema: indexes as tables, namespaces as children. Custom command parser (`parsePineconeCommand`): `QUERY vector=[...] topK=N`, `UPSERT id=... vector=[...]`, `DELETE ids=[...]`, `STATS `, `LIST `. Optional methods: `getEstimatedRowCount` (describeIndexStats), `getTableStatistics`. Connection form shows API key field instead of host/port/auth. diff --git a/README.md b/README.md index 9525725..b05d864 100644 --- a/README.md +++ b/README.md @@ -257,6 +257,7 @@ All shortcuts use physical key codes — work on any keyboard layout. | Redis | TCP | [ioredis](https://www.npmjs.com/package/ioredis) | | ClickHouse | HTTP | [@clickhouse/client](https://www.npmjs.com/package/@clickhouse/client) | | SQLite | File | [better-sqlite3](https://www.npmjs.com/package/better-sqlite3) | +| Pinecone | HTTPS | [@pinecone-database/pinecone](https://www.npmjs.com/package/@pinecone-database/pinecone) | ## Development diff --git a/l10n/nls/package.nls.json b/l10n/nls/package.nls.json index 9c11508..70e7186 100644 --- a/l10n/nls/package.nls.json +++ b/l10n/nls/package.nls.json @@ -1,5 +1,5 @@ { - "extension.description": "Database management tool for VS Code — browse schemas, run queries, and inspect data across PostgreSQL, Redis, ClickHouse, and SQLite.", + "extension.description": "Database management tool for VS Code — browse schemas, run queries, and inspect data across PostgreSQL, Redis, ClickHouse, SQLite, and Pinecone.", "view.connections.name": "Connections", "view.queryHistory.name": "Query History", "command.addConnection": "Add Connection", diff --git a/package-lock.json b/package-lock.json index 1afb0a8..b13c2a8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { + "@pinecone-database/pinecone": "^7.2.0", "@vscode-elements/elements": "^2.5.1", "@vscode/codicons": "^0.0.45", "better-sqlite3": "^12.8.0", @@ -1275,6 +1276,15 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/@pinecone-database/pinecone": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@pinecone-database/pinecone/-/pinecone-7.2.0.tgz", + "integrity": "sha512-urGsnNDWSSqSaWdyEF2P6V5bTNtRA6yMQTheFYAKKwk7mkloRBBETtT2ADF5Y8gJTr88pW5D8NTcCCLe+f0e2Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", diff --git a/package.json b/package.json index bf089cd..a9919ed 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,8 @@ "redis", "clickhouse", "sqlite", + "pinecone", + "vector", "sql", "query", "schema" @@ -82,6 +84,13 @@ "fontPath": "resources/viewstor-icons.woff2", "fontCharacter": "\\E004" } + }, + "viewstor-pinecone": { + "description": "Pinecone", + "default": { + "fontPath": "resources/viewstor-icons.woff2", + "fontCharacter": "\\E005" + } } }, "mcpServerDefinitionProviders": [ @@ -628,6 +637,7 @@ "webpack-cli": "^5.1.0" }, "dependencies": { + "@pinecone-database/pinecone": "^7.2.0", "@vscode-elements/elements": "^2.5.1", "@vscode/codicons": "^0.0.45", "better-sqlite3": "^12.8.0", diff --git a/src/drivers/index.ts b/src/drivers/index.ts index f1fbc8c..537142d 100644 --- a/src/drivers/index.ts +++ b/src/drivers/index.ts @@ -4,6 +4,7 @@ import { PostgresDriver } from './postgres'; import { RedisDriver } from './redis'; import { ClickHouseDriver } from './clickhouse'; import { SqliteDriver } from './sqlite'; +import { PineconeDriver } from './pinecone'; export function createDriver(type: DatabaseType): DatabaseDriver { switch (type) { @@ -15,6 +16,8 @@ export function createDriver(type: DatabaseType): DatabaseDriver { return new ClickHouseDriver(); case 'sqlite': return new SqliteDriver(); + case 'pinecone': + return new PineconeDriver(); default: throw new Error(`Unsupported database type: ${type}`); } diff --git a/src/drivers/pinecone.ts b/src/drivers/pinecone.ts new file mode 100644 index 0000000..ae4e547 --- /dev/null +++ b/src/drivers/pinecone.ts @@ -0,0 +1,374 @@ +import { DatabaseDriver } from '../types/driver'; +import { ConnectionConfig } from '../types/connection'; +import { QueryResult, QueryColumn, MAX_RESULT_ROWS } from '../types/query'; +import { SchemaObject, TableInfo, TableStatistic } from '../types/schema'; +import { wrapError } from '../utils/errors'; + +let PineconeModule: typeof import('@pinecone-database/pinecone') | undefined; + +function requirePinecone(): typeof import('@pinecone-database/pinecone') { + if (!PineconeModule) { + // eslint-disable-next-line @typescript-eslint/no-require-imports + PineconeModule = require('@pinecone-database/pinecone'); + } + return PineconeModule!; +} + +type PineconeClient = InstanceType; +type PineconeIndex = ReturnType; + +export class PineconeDriver implements DatabaseDriver { + private client: PineconeClient | undefined; + private indexCache: Map = new Map(); + + async connect(config: ConnectionConfig): Promise { + const { Pinecone } = requirePinecone(); + this.client = new Pinecone({ apiKey: config.password || '' }); + const indexes = await this.client.listIndexes(); + if (!indexes.indexes || indexes.indexes.length === 0) { + return; + } + this.indexCache.clear(); + for (const idx of indexes.indexes) { + this.indexCache.set(idx.name, { + dimension: idx.dimension ?? 0, + metric: idx.metric, + host: idx.host, + }); + } + } + + async disconnect(): Promise { + this.client = undefined; + this.indexCache.clear(); + } + + async ping(): Promise { + const indexes = await this.client!.listIndexes(); + return Array.isArray(indexes.indexes); + } + + async execute(query: string): Promise { + const start = Date.now(); + try { + const parsed = parsePineconeCommand(query); + if (!parsed) { + return { columns: [], rows: [], rowCount: 0, executionTimeMs: 0, error: 'Unsupported command. Use: QUERY vector=[...] topK=N [namespace=ns] [filter={...}], UPSERT id=... vector=[...] [metadata={...}] [namespace=ns], DELETE ids=[...] [namespace=ns], STATS , LIST [namespace=ns] [prefix=...] [limit=N]' }; + } + + const index = this.getIndex(parsed.index); + + switch (parsed.command) { + case 'QUERY': return await this.executeQuery(index, parsed, start); + case 'UPSERT': return await this.executeUpsert(index, parsed, start); + case 'DELETE': return await this.executeDelete(index, parsed, start); + case 'STATS': return await this.executeStats(index, start); + case 'LIST': return await this.executeList(index, parsed, start); + default: + return { columns: [], rows: [], rowCount: 0, executionTimeMs: Date.now() - start, error: `Unknown command: ${parsed.command}` }; + } + } catch (err) { + return { columns: [], rows: [], rowCount: 0, executionTimeMs: Date.now() - start, error: wrapError(err) }; + } + } + + async getSchema(): Promise { + const indexes = await this.client!.listIndexes(); + const result: SchemaObject[] = []; + + for (const idx of indexes.indexes || []) { + const indexObj: SchemaObject = { + name: idx.name, + type: 'table', + detail: `${idx.dimension}d ${idx.metric}`, + children: [], + }; + + try { + const index = this.client!.index(idx.name); + const stats = await index.describeIndexStats(); + const namespaces = stats.namespaces || {}; + for (const [nsName, nsInfo] of Object.entries(namespaces)) { + indexObj.children!.push({ + name: nsName || '(default)', + type: 'namespace', + detail: `${nsInfo.recordCount ?? 0} vectors`, + }); + } + } catch { + // If stats fail, show index without namespace details + } + + result.push(indexObj); + } + + return result; + } + + async getTableInfo(name: string): Promise { + const meta = this.indexCache.get(name); + const columns = [ + { name: 'id', dataType: 'string', nullable: false, isPrimaryKey: true }, + { name: 'values', dataType: `float32[${meta?.dimension ?? '?'}]`, nullable: false, isPrimaryKey: false }, + { name: 'metadata', dataType: 'json', nullable: true, isPrimaryKey: false }, + ]; + + let rowCount: number | undefined; + try { + const index = this.client!.index(name); + const stats = await index.describeIndexStats(); + rowCount = stats.totalRecordCount; + } catch { + // ignore + } + + return { name, columns, rowCount }; + } + + async getTableData(name: string, schema?: string, limit?: number): Promise { + const start = Date.now(); + const ns = schema || ''; + const pageSize = Math.min(limit || 100, MAX_RESULT_ROWS); + + const index = this.client!.index(name); + const nsIndex = ns ? index.namespace(ns) : index; + + try { + const listResult = await nsIndex.listPaginated({ limit: pageSize }); + const ids = (listResult.vectors || []).map(v => v.id).filter((id): id is string => !!id); + + if (ids.length === 0) { + return { + columns: [ + { name: 'id', dataType: 'string' }, + { name: 'values', dataType: 'float32[]' }, + { name: 'metadata', dataType: 'json' }, + ], + rows: [], + rowCount: 0, + executionTimeMs: Date.now() - start, + }; + } + + const fetched = await nsIndex.fetch({ ids }); + const columns: QueryColumn[] = [ + { name: 'id', dataType: 'string' }, + { name: 'values', dataType: 'float32[]' }, + { name: 'metadata', dataType: 'json' }, + ]; + + const rows: Record[] = []; + for (const id of ids) { + const record = fetched.records[id]; + if (record) { + rows.push({ + id: record.id, + values: record.values ? `[${record.values.slice(0, 8).join(', ')}${record.values.length > 8 ? ', ...' : ''}]` : null, + metadata: record.metadata ? JSON.stringify(record.metadata) : null, + }); + } + } + + return { columns, rows, rowCount: rows.length, executionTimeMs: Date.now() - start }; + } catch (err) { + return { columns: [], rows: [], rowCount: 0, executionTimeMs: Date.now() - start, error: wrapError(err) }; + } + } + + async getEstimatedRowCount(name: string, schema?: string): Promise { + const index = this.client!.index(name); + const stats = await index.describeIndexStats(); + if (schema) { + const nsStats = stats.namespaces?.[schema]; + return nsStats?.recordCount ?? 0; + } + return stats.totalRecordCount ?? 0; + } + + async getTableStatistics(name: string): Promise { + const index = this.client!.index(name); + const stats = await index.describeIndexStats(); + const meta = this.indexCache.get(name); + const result: TableStatistic[] = [ + { key: 'row_count', label: 'Total vectors', value: stats.totalRecordCount ?? 0, unit: 'count' }, + { key: 'dimension', label: 'Dimension', value: meta?.dimension ?? stats.dimension ?? 0, unit: 'count' }, + { key: 'metric', label: 'Distance metric', value: meta?.metric ?? 'unknown', unit: 'text' }, + { key: 'namespaces', label: 'Namespaces', value: Object.keys(stats.namespaces || {}).length, unit: 'count' }, + { key: 'index_fullness', label: 'Index fullness', value: stats.indexFullness != null ? Math.round(stats.indexFullness * 10000) / 100 : null, unit: 'percent' }, + ]; + return result; + } + + private getIndex(name: string): PineconeIndex { + return this.client!.index(name); + } + + private async executeQuery(index: PineconeIndex, parsed: ParsedCommand, start: number): Promise { + const vector = JSON.parse(parsed.params.vector || '[]') as number[]; + const topK = parseInt(parsed.params.topk || parsed.params.topK || '10', 10); + const ns = parsed.params.namespace; + const filterStr = parsed.params.filter; + const includeMetadata = parsed.params.includeMetadata !== 'false'; + + const nsIndex = ns ? index.namespace(ns) : index; + const queryArgs: Parameters[0] = { + vector, + topK, + includeValues: true, + includeMetadata, + }; + if (filterStr) { + queryArgs.filter = JSON.parse(filterStr); + } + const result = await nsIndex.query(queryArgs); + + const columns: QueryColumn[] = [ + { name: 'id', dataType: 'string' }, + { name: 'score', dataType: 'float' }, + { name: 'values', dataType: 'float32[]' }, + { name: 'metadata', dataType: 'json' }, + ]; + + const rows = result.matches.map(m => ({ + id: m.id, + score: m.score ?? null, + values: m.values ? `[${m.values.slice(0, 8).join(', ')}${m.values.length > 8 ? ', ...' : ''}]` : null, + metadata: m.metadata ? JSON.stringify(m.metadata) : null, + })); + + return { columns, rows, rowCount: rows.length, executionTimeMs: Date.now() - start }; + } + + private async executeUpsert(index: PineconeIndex, parsed: ParsedCommand, start: number): Promise { + const id = parsed.params.id; + const vector = JSON.parse(parsed.params.vector || '[]'); + const ns = parsed.params.namespace; + const metadataStr = parsed.params.metadata; + + if (!id) { + return { columns: [], rows: [], rowCount: 0, executionTimeMs: Date.now() - start, error: 'id is required for UPSERT' }; + } + + const record: { id: string; values: number[]; metadata?: Record } = { id, values: vector }; + if (metadataStr) { + record.metadata = JSON.parse(metadataStr); + } + + const nsIndex = ns ? index.namespace(ns) : index; + await nsIndex.upsert({ records: [record] }); + + return { + columns: [{ name: 'result', dataType: 'string' }], + rows: [{ result: `Upserted vector "${id}"` }], + rowCount: 1, + affectedRows: 1, + executionTimeMs: Date.now() - start, + }; + } + + private async executeDelete(index: PineconeIndex, parsed: ParsedCommand, start: number): Promise { + const idsStr = parsed.params.ids; + const ns = parsed.params.namespace; + const deleteAll = parsed.params.deleteAll === 'true' || parsed.params.all === 'true'; + + const nsIndex = ns ? index.namespace(ns) : index; + + if (deleteAll) { + await nsIndex.deleteAll(); + return { + columns: [{ name: 'result', dataType: 'string' }], + rows: [{ result: 'Deleted all vectors' + (ns ? ` in namespace "${ns}"` : '') }], + rowCount: 1, + executionTimeMs: Date.now() - start, + }; + } + + if (!idsStr) { + return { columns: [], rows: [], rowCount: 0, executionTimeMs: Date.now() - start, error: 'ids=[...] or all=true is required for DELETE' }; + } + + const ids = JSON.parse(idsStr); + await nsIndex.deleteMany(ids); + + return { + columns: [{ name: 'result', dataType: 'string' }], + rows: [{ result: `Deleted ${ids.length} vector(s)` }], + rowCount: 1, + affectedRows: ids.length, + executionTimeMs: Date.now() - start, + }; + } + + private async executeStats(index: PineconeIndex, start: number): Promise { + const stats = await index.describeIndexStats(); + const columns: QueryColumn[] = [ + { name: 'metric', dataType: 'string' }, + { name: 'value', dataType: 'string' }, + ]; + + const rows: Record[] = [ + { metric: 'Total vectors', value: stats.totalRecordCount }, + { metric: 'Dimension', value: stats.dimension }, + { metric: 'Index fullness', value: stats.indexFullness != null ? `${Math.round(stats.indexFullness * 100)}%` : 'N/A' }, + ]; + + for (const [nsName, nsInfo] of Object.entries(stats.namespaces || {})) { + rows.push({ metric: `Namespace "${nsName || '(default)'}"`, value: `${nsInfo.recordCount ?? 0} vectors` }); + } + + return { columns, rows, rowCount: rows.length, executionTimeMs: Date.now() - start }; + } + + private async executeList(index: PineconeIndex, parsed: ParsedCommand, start: number): Promise { + const ns = parsed.params.namespace; + const prefix = parsed.params.prefix; + const limit = parseInt(parsed.params.limit || '100', 10); + + const nsIndex = ns ? index.namespace(ns) : index; + const listArgs: { limit: number; prefix?: string } = { limit: Math.min(limit, MAX_RESULT_ROWS) }; + if (prefix) listArgs.prefix = prefix; + + const result = await nsIndex.listPaginated(listArgs); + const ids = (result.vectors || []).map(v => v.id).filter((id): id is string => !!id); + + const columns: QueryColumn[] = [{ name: 'id', dataType: 'string' }]; + const rows = ids.map(id => ({ id })); + + return { columns, rows, rowCount: rows.length, executionTimeMs: Date.now() - start }; + } +} + +interface ParsedCommand { + command: string; + index: string; + params: Record; +} + +export function parsePineconeCommand(input: string): ParsedCommand | null { + const trimmed = input.trim(); + if (!trimmed) return null; + + const firstSpace = trimmed.indexOf(' '); + if (firstSpace === -1) return null; + + const command = trimmed.substring(0, firstSpace).toUpperCase(); + const rest = trimmed.substring(firstSpace + 1).trim(); + + const secondSpace = rest.indexOf(' '); + const index = secondSpace === -1 ? rest : rest.substring(0, secondSpace); + const paramsStr = secondSpace === -1 ? '' : rest.substring(secondSpace + 1).trim(); + + const params: Record = {}; + if (paramsStr) { + const paramRegex = /(\w+)=((?:\[.*?\]|\{.*?\}|"[^"]*"|'[^']*'|\S+))/g; + let match; + while ((match = paramRegex.exec(paramsStr)) !== null) { + params[match[1]] = match[2]; + } + } + + const validCommands = ['QUERY', 'UPSERT', 'DELETE', 'STATS', 'LIST']; + if (!validCommands.includes(command)) return null; + + return { command, index, params }; +} diff --git a/src/mcp-server/index.ts b/src/mcp-server/index.ts index 05a8acd..92dc7e7 100644 --- a/src/mcp-server/index.ts +++ b/src/mcp-server/index.ts @@ -86,16 +86,16 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({ }, { name: 'add_connection', - description: 'Add a new database connection. For SQLite: set type="sqlite", database="/path/to/file.db" (or ":memory:"), host and port are ignored.', + description: 'Add a new database connection. For SQLite: set type="sqlite", database="/path/to/file.db" (or ":memory:"), host and port are ignored. For Pinecone: set type="pinecone", password=API key, host and port are ignored.', inputSchema: { type: 'object' as const, properties: { name: { type: 'string', description: 'Display name' }, - type: { type: 'string', enum: ['postgresql', 'redis', 'clickhouse', 'sqlite'], description: 'Database type' }, - host: { type: 'string', description: 'Host (ignored for SQLite)' }, - port: { type: 'number', description: 'Port (ignored for SQLite)' }, + type: { type: 'string', enum: ['postgresql', 'redis', 'clickhouse', 'sqlite', 'pinecone'], description: 'Database type' }, + host: { type: 'string', description: 'Host (ignored for SQLite and Pinecone)' }, + port: { type: 'number', description: 'Port (ignored for SQLite and Pinecone)' }, username: { type: 'string', description: 'Username' }, - password: { type: 'string', description: 'Password' }, + password: { type: 'string', description: 'Password (API key for Pinecone)' }, database: { type: 'string', description: 'Database name, or file path for SQLite (e.g. "/tmp/test.db", ":memory:")' }, ssl: { type: 'boolean', description: 'Use SSL' }, readonly: { type: 'boolean', description: 'Read-only mode' }, diff --git a/src/services/importService.ts b/src/services/importService.ts index 28e955a..dce6c0b 100644 --- a/src/services/importService.ts +++ b/src/services/importService.ts @@ -83,6 +83,7 @@ function mapDBeaverProvider(provider?: string, driver?: string): DatabaseType | if (p.includes('redis') || p.includes('iredis')) return 'redis'; if (p.includes('clickhouse')) return 'clickhouse'; if (p.includes('sqlite')) return 'sqlite'; + if (p.includes('pinecone')) return 'pinecone'; return null; } @@ -146,6 +147,7 @@ function mapDataGripDriver(driver?: string): DatabaseType | null { if (d.includes('redis')) return 'redis'; if (d.includes('clickhouse')) return 'clickhouse'; if (d.includes('sqlite')) return 'sqlite'; + if (d.includes('pinecone')) return 'pinecone'; return null; } diff --git a/src/test/driverContract.test.ts b/src/test/driverContract.test.ts index 1981c54..d55568a 100644 --- a/src/test/driverContract.test.ts +++ b/src/test/driverContract.test.ts @@ -27,10 +27,15 @@ vi.mock('ssh2', () => ({ Client: class MockSSHClient {}, })); +vi.mock('@pinecone-database/pinecone', () => ({ + Pinecone: class MockPinecone {}, +})); + import { PostgresDriver } from '../drivers/postgres'; import { RedisDriver } from '../drivers/redis'; import { ClickHouseDriver } from '../drivers/clickhouse'; import { SqliteDriver } from '../drivers/sqlite'; +import { PineconeDriver } from '../drivers/pinecone'; import { createDriver } from '../drivers'; import type { DatabaseDriver } from '../types/driver'; @@ -57,7 +62,7 @@ const OPTIONAL_METHODS: (keyof DatabaseDriver)[] = [ interface DriverSpec { name: string; - type: 'postgresql' | 'redis' | 'clickhouse' | 'sqlite'; + type: 'postgresql' | 'redis' | 'clickhouse' | 'sqlite' | 'pinecone'; // eslint-disable-next-line @typescript-eslint/no-explicit-any DriverClass: new (...args: any[]) => DatabaseDriver; expectedOptional: (keyof DatabaseDriver)[]; @@ -113,6 +118,15 @@ const DRIVER_SPECS: DriverSpec[] = [ 'getTableStatistics', ], }, + { + name: 'PineconeDriver', + type: 'pinecone', + DriverClass: PineconeDriver, + expectedOptional: [ + 'getEstimatedRowCount', + 'getTableStatistics', + ], + }, ]; describe('DatabaseDriver contract', () => { diff --git a/src/test/pineconeParser.test.ts b/src/test/pineconeParser.test.ts new file mode 100644 index 0000000..faec563 --- /dev/null +++ b/src/test/pineconeParser.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect } from 'vitest'; +import { parsePineconeCommand } from '../drivers/pinecone'; + +describe('parsePineconeCommand', () => { + it('returns null for empty input', () => { + expect(parsePineconeCommand('')).toBeNull(); + expect(parsePineconeCommand(' ')).toBeNull(); + }); + + it('returns null for single word (no index)', () => { + expect(parsePineconeCommand('QUERY')).toBeNull(); + }); + + it('returns null for unknown commands', () => { + expect(parsePineconeCommand('SELECT my-index')).toBeNull(); + expect(parsePineconeCommand('INSERT my-index id=1')).toBeNull(); + }); + + it('parses QUERY with vector and topK', () => { + const result = parsePineconeCommand('QUERY my-index vector=[0.1,0.2,0.3] topK=5'); + expect(result).toEqual({ + command: 'QUERY', + index: 'my-index', + params: { vector: '[0.1,0.2,0.3]', topK: '5' }, + }); + }); + + it('parses QUERY with namespace and filter', () => { + const result = parsePineconeCommand('QUERY idx vector=[1,2] namespace=ns1 filter={"genre":"comedy"}'); + expect(result).toEqual({ + command: 'QUERY', + index: 'idx', + params: { vector: '[1,2]', namespace: 'ns1', filter: '{"genre":"comedy"}' }, + }); + }); + + it('parses UPSERT with id, vector, and metadata', () => { + const result = parsePineconeCommand('UPSERT my-index id=vec1 vector=[0.1,0.2] metadata={"key":"val"}'); + expect(result).toEqual({ + command: 'UPSERT', + index: 'my-index', + params: { id: 'vec1', vector: '[0.1,0.2]', metadata: '{"key":"val"}' }, + }); + }); + + it('parses DELETE with ids', () => { + const result = parsePineconeCommand('DELETE my-index ids=["id1","id2"]'); + expect(result).toEqual({ + command: 'DELETE', + index: 'my-index', + params: { ids: '["id1","id2"]' }, + }); + }); + + it('parses DELETE with all=true', () => { + const result = parsePineconeCommand('DELETE my-index all=true namespace=ns1'); + expect(result).toEqual({ + command: 'DELETE', + index: 'my-index', + params: { all: 'true', namespace: 'ns1' }, + }); + }); + + it('parses STATS with index only', () => { + const result = parsePineconeCommand('STATS my-index'); + expect(result).toEqual({ + command: 'STATS', + index: 'my-index', + params: {}, + }); + }); + + it('parses LIST with namespace and prefix', () => { + const result = parsePineconeCommand('LIST my-index namespace=ns1 prefix=doc_ limit=50'); + expect(result).toEqual({ + command: 'LIST', + index: 'my-index', + params: { namespace: 'ns1', prefix: 'doc_', limit: '50' }, + }); + }); + + it('is case-insensitive for command', () => { + const result = parsePineconeCommand('query my-index vector=[1]'); + expect(result?.command).toBe('QUERY'); + }); + + it('handles extra whitespace', () => { + const result = parsePineconeCommand(' STATS my-index '); + expect(result).toEqual({ + command: 'STATS', + index: 'my-index', + params: {}, + }); + }); +}); diff --git a/src/types/connection.ts b/src/types/connection.ts index 6a9afe9..2bd8281 100644 --- a/src/types/connection.ts +++ b/src/types/connection.ts @@ -1,4 +1,4 @@ -export type DatabaseType = 'postgresql' | 'redis' | 'clickhouse' | 'sqlite'; +export type DatabaseType = 'postgresql' | 'redis' | 'clickhouse' | 'sqlite' | 'pinecone'; export interface ConnectionConfig { id: string; @@ -66,4 +66,5 @@ export const DEFAULT_PORTS: Record = { redis: 6379, clickhouse: 8123, sqlite: 0, + pinecone: 0, }; diff --git a/src/types/schema.ts b/src/types/schema.ts index b2450c4..97ff59c 100644 --- a/src/types/schema.ts +++ b/src/types/schema.ts @@ -25,7 +25,8 @@ export type SchemaObjectType = | 'keyspace' | 'trigger' | 'sequence' - | 'group'; + | 'group' + | 'namespace'; export interface ColumnInfo { name: string; diff --git a/src/views/connectionForm.ts b/src/views/connectionForm.ts index 18e18ed..a93f349 100644 --- a/src/views/connectionForm.ts +++ b/src/views/connectionForm.ts @@ -232,6 +232,7 @@ export class ConnectionFormPanel { Redis ClickHouse SQLite + Pinecone @@ -281,6 +282,14 @@ export class ConnectionFormPanel { + +
Use SSL
diff --git a/src/views/connectionTree.ts b/src/views/connectionTree.ts index fb74821..2560563 100644 --- a/src/views/connectionTree.ts +++ b/src/views/connectionTree.ts @@ -315,6 +315,7 @@ function schemaIcon(type: SchemaObjectType): string { case 'trigger': return 'zap'; case 'sequence': return 'symbol-number'; case 'group': return 'list-flat'; + case 'namespace': return 'symbol-namespace'; default: return 'symbol-misc'; } } diff --git a/src/webview/scripts/connection-form.js b/src/webview/scripts/connection-form.js index 2d026de..b13be69 100644 --- a/src/webview/scripts/connection-form.js +++ b/src/webview/scripts/connection-form.js @@ -2,7 +2,7 @@ (function () { const vscode = acquireVsCodeApi(); - const defaultPorts = { postgresql: 5432, redis: 6379, clickhouse: 8123, sqlite: 0 }; + const defaultPorts = { postgresql: 5432, redis: 6379, clickhouse: 8123, sqlite: 0, pinecone: 0 }; // VS Code custom elements expose `value` / `checked` properties just like // native form controls and emit `change` / `input` events. Wrappers below @@ -56,16 +56,19 @@ function updateFieldVisibility() { const isRedis = dbType.value === 'redis'; const isSqlite = dbType.value === 'sqlite'; - const isNetworkDb = !isRedis && !isSqlite; + const isPinecone = dbType.value === 'pinecone'; + const isNetworkDb = !isRedis && !isSqlite && !isPinecone; authFields.style.display = isNetworkDb ? 'block' : 'none'; dbFields.style.display = isNetworkDb ? 'block' : 'none'; - if (hostPortRow) hostPortRow.style.display = isSqlite ? 'none' : ''; + if (hostPortRow) hostPortRow.style.display = (isSqlite || isPinecone) ? 'none' : ''; redisDbField.classList.toggle('hidden', !isRedis); sqliteFileField.classList.toggle('hidden', !isSqlite); - if (sslGroup) sslGroup.style.display = isSqlite ? 'none' : ''; - if (proxyGroup) proxyGroup.style.display = isSqlite ? 'none' : ''; + var pineconeApiKeyField = $('pineconeApiKeyField'); + if (pineconeApiKeyField) pineconeApiKeyField.classList.toggle('hidden', !isPinecone); + if (sslGroup) sslGroup.style.display = (isSqlite || isPinecone) ? 'none' : ''; + if (proxyGroup) proxyGroup.style.display = (isSqlite || isPinecone) ? 'none' : ''; const hiddenSchemasGroup = $('hiddenSchemasGroup'); - if (hiddenSchemasGroup) hiddenSchemasGroup.style.display = isSqlite ? 'none' : ''; + if (hiddenSchemasGroup) hiddenSchemasGroup.style.display = (isSqlite || isPinecone) ? 'none' : ''; updateProxyVisibility(); } @@ -246,6 +249,7 @@ function getFormData() { const isRedis = dbType.value === 'redis'; const isSqlite = dbType.value === 'sqlite'; + const isPinecone = dbType.value === 'pinecone'; return { id: connId.value || '', name: valueOf(connName).trim(), @@ -253,9 +257,9 @@ host: valueOf(host).trim(), port: valueOf(port), username: valueOf(username).trim(), - password: valueOf(password), + password: isPinecone ? valueOf($('pineconeApiKey')) : valueOf(password), database: isRedis ? valueOf(redisDb) : isSqlite ? valueOf(sqliteFile).trim() : database.value.trim(), - databases: (isRedis || isSqlite) ? '' : databases.value.trim(), + databases: (isRedis || isSqlite || isPinecone) ? '' : databases.value.trim(), ssl: ssl.checked ? 'true' : 'false', color: colorPicker.getValue(), readonly: readonlyMode.checked ? 'true' : 'false', @@ -287,10 +291,14 @@ function validate() { let valid = true; const isSqlite = dbType.value === 'sqlite'; + const isPinecone = dbType.value === 'pinecone'; document.querySelectorAll('.error-text').forEach(function (el) { el.remove(); }); if (!valueOf(connName).trim()) { showError(connName, 'Connection name is required'); valid = false; } - if (isSqlite) { + if (isPinecone) { + var apiKey = $('pineconeApiKey'); + if (apiKey && !valueOf(apiKey).trim()) { showError(apiKey, 'API key is required'); valid = false; } + } else if (isSqlite) { if (!valueOf(sqliteFile).trim()) { showError(sqliteFile, 'Database file path is required'); valid = false; } } else { if (!valueOf(host).trim()) { showError(host, 'Host is required'); valid = false; } @@ -342,6 +350,7 @@ databases.value = (c.databases || []).join(','); if (c.type === 'redis') redisDb.value = c.database || '0'; if (c.type === 'sqlite') sqliteFile.value = c.database || ''; + if (c.type === 'pinecone' && $('pineconeApiKey')) $('pineconeApiKey').value = c.password || ''; initChips(); ssl.checked = !!c.ssl; colorPicker.setValue(c.color || ''); diff --git a/webpack.config.js b/webpack.config.js index 2ee97d6..e2e5549 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -32,6 +32,7 @@ module.exports = (_env, argv) => { ssh2: 'commonjs ssh2', 'cpu-features': 'commonjs cpu-features', 'better-sqlite3': 'commonjs better-sqlite3', + '@pinecone-database/pinecone': 'commonjs @pinecone-database/pinecone', }, plugins: [ new webpack.DefinePlugin({ __DEV__: JSON.stringify(isDev) }), @@ -66,6 +67,7 @@ module.exports = (_env, argv) => { ssh2: 'commonjs ssh2', 'cpu-features': 'commonjs cpu-features', 'better-sqlite3': 'commonjs better-sqlite3', + '@pinecone-database/pinecone': 'commonjs @pinecone-database/pinecone', }, plugins: [ new webpack.DefinePlugin({ __DEV__: JSON.stringify(isDev) }), From 8d85d6ef42d22af755f92a6db183dbe12e37ce82 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Apr 2026 06:19:25 +0000 Subject: [PATCH 2/3] fix(review): bracket-counting parser for nested JSON in Pinecone commands The regex-based parser truncated nested JSON values like filter={"$and":[{"a":1},{"b":2}]} at the first closing brace. Replace with a bracket-counting parser that handles nested structures and string escaping within JSON. https://claude.ai/code/session_01RwjTCNaBKhphYNzopBwqK1 --- src/drivers/pinecone.ts | 43 ++++++++++++++++++++++++++++++--- src/test/pineconeParser.test.ts | 18 ++++++++++++++ 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/src/drivers/pinecone.ts b/src/drivers/pinecone.ts index ae4e547..e40fc28 100644 --- a/src/drivers/pinecone.ts +++ b/src/drivers/pinecone.ts @@ -360,10 +360,45 @@ export function parsePineconeCommand(input: string): ParsedCommand | null { const params: Record = {}; if (paramsStr) { - const paramRegex = /(\w+)=((?:\[.*?\]|\{.*?\}|"[^"]*"|'[^']*'|\S+))/g; - let match; - while ((match = paramRegex.exec(paramsStr)) !== null) { - params[match[1]] = match[2]; + let pos = 0; + while (pos < paramsStr.length) { + while (pos < paramsStr.length && paramsStr[pos] === ' ') pos++; + if (pos >= paramsStr.length) break; + const eqPos = paramsStr.indexOf('=', pos); + if (eqPos === -1) break; + const key = paramsStr.substring(pos, eqPos); + pos = eqPos + 1; + if (pos >= paramsStr.length) break; + const ch = paramsStr[pos]; + if (ch === '[' || ch === '{') { + const open = ch; + const close = ch === '[' ? ']' : '}'; + let depth = 0; + const start = pos; + let inStr = false; + while (pos < paramsStr.length) { + const c = paramsStr[pos]; + if (c === '"' && (pos === 0 || paramsStr[pos - 1] !== '\\')) inStr = !inStr; + else if (!inStr) { + if (c === open) depth++; + else if (c === close) depth--; + } + pos++; + if (depth === 0) break; + } + params[key] = paramsStr.substring(start, pos); + } else if (ch === '"' || ch === '\'') { + const quote = ch; + pos++; + const start = pos; + while (pos < paramsStr.length && paramsStr[pos] !== quote) pos++; + params[key] = paramsStr.substring(start, pos); + if (pos < paramsStr.length) pos++; + } else { + const start = pos; + while (pos < paramsStr.length && paramsStr[pos] !== ' ') pos++; + params[key] = paramsStr.substring(start, pos); + } } } diff --git a/src/test/pineconeParser.test.ts b/src/test/pineconeParser.test.ts index faec563..26e5696 100644 --- a/src/test/pineconeParser.test.ts +++ b/src/test/pineconeParser.test.ts @@ -92,4 +92,22 @@ describe('parsePineconeCommand', () => { params: {}, }); }); + + it('handles nested JSON in filter param', () => { + const result = parsePineconeCommand('QUERY idx vector=[1,2] filter={"$and":[{"genre":"comedy"},{"year":{"$gte":2020}}]}'); + expect(result).toEqual({ + command: 'QUERY', + index: 'idx', + params: { vector: '[1,2]', filter: '{"$and":[{"genre":"comedy"},{"year":{"$gte":2020}}]}' }, + }); + }); + + it('handles nested arrays in vector param', () => { + const result = parsePineconeCommand('UPSERT idx id=v1 vector=[0.1,0.2,0.3] metadata={"tags":["a","b"]}'); + expect(result).toEqual({ + command: 'UPSERT', + index: 'idx', + params: { id: 'v1', vector: '[0.1,0.2,0.3]', metadata: '{"tags":["a","b"]}' }, + }); + }); }); From d55e08e99df7d25df4a476743eba87b0b544ba26 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Apr 2026 06:19:33 +0000 Subject: [PATCH 3/3] fix(review): allow Pinecone read commands through MCP readonly check isReadOnlyQuery() only recognized SQL verbs, blocking all Pinecone commands (QUERY, STATS, LIST) on read-only connections via MCP. Add Pinecone read verbs to READ_VERB_RE and update the error message. https://claude.ai/code/session_01RwjTCNaBKhphYNzopBwqK1 --- src/mcp-server/index.ts | 2 +- src/test/queryHelpers.test.ts | 3 +++ src/utils/queryHelpers.ts | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/mcp-server/index.ts b/src/mcp-server/index.ts index 92dc7e7..6a976ac 100644 --- a/src/mcp-server/index.ts +++ b/src/mcp-server/index.ts @@ -185,7 +185,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { const { connectionId, query, database } = args as { connectionId: string; query: string; database?: string }; const config = store.get(connectionId); if (config?.readonly && !isReadOnlyQuery(query)) { - return errorResponse('Connection is read-only. Only SELECT, EXPLAIN, SHOW, and WITH queries are allowed.'); + return errorResponse('Connection is read-only. Only SELECT, EXPLAIN, SHOW, WITH, and read-only driver commands (QUERY, STATS, LIST) are allowed.'); } const driver = await resolveDriver(connectionId, database); return jsonResponse(formatExecuteQuery(await driver.execute(query))); diff --git a/src/test/queryHelpers.test.ts b/src/test/queryHelpers.test.ts index 5376096..acedac8 100644 --- a/src/test/queryHelpers.test.ts +++ b/src/test/queryHelpers.test.ts @@ -68,6 +68,9 @@ describe('isReadOnlyQuery — happy path', () => { 'DESCRIBE users', 'DESC users', ' SELECT 1 ', + 'QUERY my-index vector=[0.1,0.2] topK=5', + 'STATS my-index', + 'LIST my-index namespace=ns1', ])('accepts %s', (sql) => { expect(isReadOnlyQuery(sql)).toBe(true); }); diff --git a/src/utils/queryHelpers.ts b/src/utils/queryHelpers.ts index b5ed734..de78ba7 100644 --- a/src/utils/queryHelpers.ts +++ b/src/utils/queryHelpers.ts @@ -1,6 +1,6 @@ import { DatabaseDriver } from '../types/driver'; -const READ_VERB_RE = /^\s*(SELECT|WITH|EXPLAIN|SHOW|VALUES|TABLE|DESCRIBE|DESC)\b/i; +const READ_VERB_RE = /^\s*(SELECT|WITH|EXPLAIN|SHOW|VALUES|TABLE|DESCRIBE|DESC|QUERY|STATS|LIST)\b/i; // Verbs that mutate state — anywhere they appear (CTE body, EXPLAIN target, etc.) the statement is NOT read-only. const WRITE_VERB_RE = /\b(INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|TRUNCATE|GRANT|REVOKE|MERGE|COPY|REPLACE|VACUUM|REFRESH|REINDEX|CALL|LOCK|NOTIFY|LISTEN|UNLISTEN|RESET|COMMIT|ROLLBACK|SAVEPOINT)\b/i; // PG: EXPLAIN ANALYZE actually executes the inner statement. ANALYZE may sit inside a parenthesized option list.