@bytebase/dbhub
Version:
Token-efficient database MCP server for PostgreSQL, MySQL, MariaDB, SQL Server, Oracle, SQLite
426 lines (423 loc) • 13.8 kB
JavaScript
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
};