UNPKG

@bytebase/dbhub

Version:

Token-efficient database MCP server for PostgreSQL, MySQL, MariaDB, SQL Server, Oracle, SQLite

426 lines (423 loc) • 13.8 kB
import { quoteIdentifier } from "./chunk-X2EC47W4.js"; import { SQLRowLimiter, closeQuietly } from "./chunk-YIGJHEU6.js"; import "./chunk-ABTVMHD6.js"; import { ConnectorRegistry, SafeURL, obfuscateDSNPassword, splitSQLStatements, stripCommentsAndStrings } from "./chunk-IXC4NQGC.js"; // src/connectors/sqlite/suppress-experimental-warning.ts var installed = false; function suppressSqliteExperimentalWarning() { if (installed) { return; } installed = true; const originalEmitWarning = process.emitWarning.bind(process); process.emitWarning = (warning, ...args) => { const message = typeof warning === "string" ? warning : warning?.message; if (message && message.includes("SQLite is an experimental feature")) { return; } return originalEmitWarning(warning, ...args); }; } // src/connectors/sqlite/index.ts var SQLiteDSNParser = class { async parse(dsn, config) { if (!this.isValidDSN(dsn)) { const obfuscatedDSN = obfuscateDSNPassword(dsn); const expectedFormat = this.getSampleDSN(); throw new Error( `Invalid SQLite DSN format. Provided: ${obfuscatedDSN} Expected: ${expectedFormat}` ); } try { const url = new SafeURL(dsn); let dbPath; if (url.hostname === "" && url.pathname === "/:memory:") { dbPath = ":memory:"; } else { if (url.pathname.startsWith("//")) { dbPath = url.pathname.substring(2); } else if (url.pathname.match(/^\/[A-Za-z]:\//)) { dbPath = url.pathname.substring(1); } else { dbPath = url.pathname; } } return { dbPath }; } catch (error) { throw new Error( `Failed to parse SQLite DSN: ${error instanceof Error ? error.message : String(error)}` ); } } getSampleDSN() { return "sqlite:///path/to/database.db"; } isValidDSN(dsn) { try { return dsn.startsWith("sqlite://"); } catch (error) { return false; } } }; var SQLiteConnector = class _SQLiteConnector { constructor() { this.id = "sqlite"; this.name = "SQLite"; this.dsnParser = new SQLiteDSNParser(); this.db = null; this.dbPath = ":memory:"; // Default to in-memory database // Source ID is set by ConnectorManager after cloning this.sourceId = "default"; } getId() { return this.sourceId; } clone() { return new _SQLiteConnector(); } /** * Prepare a user-facing query statement with BigInt reads enabled, so 64-bit * integer values in the result rows keep full precision. * * better-sqlite3 exposed this connection-wide via `defaultSafeIntegers(true)`; * `node:sqlite` only offers it per-statement via `setReadBigInts`. This is for * `executeSQL` only — schema introspection reads small integer flags and uses * `queryAll`/`queryOne` (plain numbers) so comparisons like `=== 1` work. */ prepare(sql) { if (!this.db) { throw new Error("Not connected to SQLite database"); } const statement = this.db.prepare(sql); statement.setReadBigInts(true); return statement; } /** * Run an introspection query and return all rows. Integers come back as plain * numbers (no `setReadBigInts`), which is what the schema-reading callers need * for boolean flag comparisons. node:sqlite types `.all()` as a generic record * array, so the result is cast to the caller's expected row shape. */ queryAll(sql, ...params) { if (!this.db) { throw new Error("Not connected to SQLite database"); } return this.db.prepare(sql).all(...params); } /** Run an introspection query and return a single row (or undefined). */ queryOne(sql, ...params) { if (!this.db) { throw new Error("Not connected to SQLite database"); } return this.db.prepare(sql).get(...params); } /** * Connect to SQLite database * Note: SQLite does not support connection timeouts as it's a local file-based database. * The config parameter is accepted for interface compliance but ignored. */ async connect(dsn, initScript, config) { const parsedConfig = await this.dsnParser.parse(dsn, config); this.dbPath = parsedConfig.dbPath; try { suppressSqliteExperimentalWarning(); const { DatabaseSync } = await import("node:sqlite"); const dbOptions = { // node:sqlite enables foreign key constraints by default, whereas // better-sqlite3 (and SQLite's own default) leaves them off. Preserve // the historical behavior; callers can opt in via `PRAGMA foreign_keys = ON`. enableForeignKeyConstraints: false }; if (config?.readonly && this.dbPath !== ":memory:") { dbOptions.readOnly = true; } this.db = new DatabaseSync(this.dbPath, dbOptions); if (initScript) { this.db.exec(initScript); } } catch (error) { if (this.db) { const db = this.db; this.db = null; await closeQuietly(() => db.close()); } console.error("Failed to connect to SQLite database:", error); throw error; } } async disconnect() { if (this.db) { try { const inTransaction = this.db.isTransaction; if (!inTransaction) { this.db.close(); } else { try { this.db.exec("ROLLBACK"); } catch (rollbackError) { } this.db.close(); } this.db = null; } catch (error) { console.error("Error during SQLite disconnect:", error); this.db = null; } } return Promise.resolve(); } async getSchemas() { if (!this.db) { throw new Error("Not connected to SQLite database"); } return ["main"]; } async getTables(schema) { if (!this.db) { throw new Error("Not connected to SQLite database"); } try { const rows = this.queryAll(` SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name `); return rows.map((row) => row.name); } catch (error) { throw error; } } async getViews(schema) { if (!this.db) { throw new Error("Not connected to SQLite database"); } try { const rows = this.queryAll(` SELECT name FROM sqlite_master WHERE type='view' AND name NOT LIKE 'sqlite_%' ORDER BY name `); return rows.map((row) => row.name); } catch (error) { throw error; } } async tableExists(tableName, schema) { if (!this.db) { throw new Error("Not connected to SQLite database"); } try { const row = this.queryOne( ` SELECT name FROM sqlite_master WHERE type='table' AND name = ? `, tableName ); return !!row; } catch (error) { throw error; } } async getTableIndexes(tableName, schema) { if (!this.db) { throw new Error("Not connected to SQLite database"); } try { const indexInfoRows = this.queryAll( ` SELECT name as index_name, 0 as is_unique FROM sqlite_master WHERE type = 'index' AND tbl_name = ? `, tableName ); const quotedTableName = quoteIdentifier(tableName, "sqlite"); const indexListRows = this.queryAll( `PRAGMA index_list(${quotedTableName})` ); const indexUniqueMap = /* @__PURE__ */ new Map(); for (const indexListRow of indexListRows) { indexUniqueMap.set(indexListRow.name, indexListRow.unique === 1); } const tableInfo = this.queryAll(`PRAGMA table_info(${quotedTableName})`); const pkColumns = tableInfo.filter((col) => col.pk > 0).map((col) => col.name); const results = []; for (const indexInfo of indexInfoRows) { const quotedIndexName = quoteIdentifier(indexInfo.index_name, "sqlite"); const indexDetailRows = this.queryAll( `PRAGMA index_info(${quotedIndexName})` ); const columnNames = indexDetailRows.map((row) => row.name); results.push({ index_name: indexInfo.index_name, column_names: columnNames, is_unique: indexUniqueMap.get(indexInfo.index_name) || false, is_primary: false }); } if (pkColumns.length > 0) { results.push({ index_name: "PRIMARY", column_names: pkColumns, is_unique: true, is_primary: true }); } return results; } catch (error) { throw error; } } async getTableSchema(tableName, schema) { if (!this.db) { throw new Error("Not connected to SQLite database"); } try { const quotedTableName = quoteIdentifier(tableName, "sqlite"); const rows = this.queryAll(`PRAGMA table_info(${quotedTableName})`); const columns = rows.map((row) => ({ column_name: row.name, data_type: row.type, // In SQLite, primary key columns are automatically NOT NULL even if notnull=0 is_nullable: row.notnull === 1 || row.pk > 0 ? "NO" : "YES", column_default: row.dflt_value, description: null })); return columns; } catch (error) { throw error; } } async getStoredProcedures(schema, routineType) { if (!this.db) { throw new Error("Not connected to SQLite database"); } return []; } async getStoredProcedureDetail(procedureName, schema) { if (!this.db) { throw new Error("Not connected to SQLite database"); } throw new Error( "SQLite does not support stored procedures. Functions are defined programmatically through the SQLite API, not stored in the database." ); } /** * Whether a statement returns rows (and so must run via `all()` rather than * `run()`). Classified on the comment/string-stripped text so a leading * attribution comment cannot hide a SELECT, matching the row limiter and the * read-only classifier. */ returnsRows(statement) { const stripped = stripCommentsAndStrings(statement, "sqlite").toLowerCase().trim(); return stripped.startsWith("select") || stripped.startsWith("with") || stripped.startsWith("explain") || stripped.startsWith("analyze") || stripped.startsWith("pragma") && (stripped.includes("table_info") || stripped.includes("index_info") || stripped.includes("index_list") || stripped.includes("foreign_key_list")); } async executeSQL(sql, options, parameters) { if (!this.db) { throw new Error("Not connected to SQLite database"); } if (options.readonly) { this.db.exec("PRAGMA query_only = ON"); } try { const statements = splitSQLStatements(sql, "sqlite"); if (statements.length === 1) { let processedStatement = statements[0]; const isReadStatement = this.returnsRows(statements[0]); let probeApplied = false; if (options.maxRows) { const rewrite = SQLRowLimiter.applyMaxRowsWithTruncationProbe( processedStatement, options.maxRows, "sqlite" ); processedStatement = rewrite.sql; probeApplied = rewrite.probeApplied; } if (isReadStatement) { const rows = parameters && parameters.length > 0 ? this.prepare(processedStatement).all(...parameters) : this.prepare(processedStatement).all(); const resultSet = { sql: statements[0], rows, rowCount: rows.length }; SQLRowLimiter.flagTruncation(resultSet, options.maxRows, probeApplied); return { resultSets: [resultSet] }; } else { let result; if (parameters && parameters.length > 0) { result = this.prepare(processedStatement).run(...parameters); } else { result = this.prepare(processedStatement).run(); } return { resultSets: [{ sql: statements[0], rows: [], rowCount: Number(result.changes) }] }; } } else { if (parameters && parameters.length > 0) { throw new Error("Parameters are not supported for multi-statement queries in SQLite"); } const readStatements = []; const writeStatements = []; for (const statement of statements) { if (this.returnsRows(statement)) { readStatements.push(statement); } else { writeStatements.push(statement); } } const resultSets = []; for (const statement of writeStatements) { if (options.readonly) { this.db.exec("PRAGMA query_only = ON"); } const result = this.prepare(statement).run(); resultSets.push({ sql: statement, rows: [], rowCount: Number(result.changes) }); } for (const statement of readStatements) { if (options.readonly) { this.db.exec("PRAGMA query_only = ON"); } const { sql: processedStatement, probeApplied } = SQLRowLimiter.applyMaxRowsWithTruncationProbe( statement, options.maxRows, "sqlite" ); const rows = this.prepare(processedStatement).all(); const resultSet = { sql: statement, rows, rowCount: rows.length }; SQLRowLimiter.flagTruncation(resultSet, options.maxRows, probeApplied); resultSets.push(resultSet); } return { resultSets }; } } finally { if (options.readonly) { try { this.db.exec("PRAGMA query_only = OFF"); } catch { } } } } }; var sqliteConnector = new SQLiteConnector(); ConnectorRegistry.register(sqliteConnector); export { SQLiteConnector };