1. Введение: Отсутствующий SPI в современных средах выполнения
В зрелых корпоративных экосистемах вроде Java или .NET инструменты для работы с базами данных опираются на стандартизированные на уровне среды выполнения интерфейсы (Service Provider Interfaces / SPI):
Java:
java.sql.Driver,java.sql.Connection,java.sql.Statementиjava.sql.ResultSet(JDBC)..NET:
System.Data.Common.DbConnection,DbCommandиDbDataReader(ADO.NET).
В этих платформах производители СУБД — будь то Oracle, PostgreSQL, MySQL или Microsoft SQL Server — создают драйверы (JAR-архивы или DLL), строго следующие данным интерфейсам. Клиентское приложение или GUI-клиент вызывает стандартный API, не погружаясь в тонкости сетевых протоколов, нюансы пулов соединений или специфику ошибок конкретной базы.
Проблема экосистемы JavaScript / TypeScript
В Node.js, Bun и Deno отсутствует системный стандарт драйверов баз данных, аналогичный JDBC.
Вместо этого экосистема npm представляет собой набор независимых комьюнити-драйверов:
PostgreSQL использует
pg(node-postgres).MySQL использует
mysql2.SQLite полагается на нативные биндинги вроде
better-sqlite3,bun:sqliteилиnode:sqlite.Oracle DB использует
oracledb.NoSQL-системы вроде Redis (
ioredis), MongoDB (mongodb) и Cassandra (cassandra-driver) используют кардинально разные парадигмы (дескрипторы документов, ключевые команды, бинарные буферы).
При создании универсального веб/десктоп-клиента или СУБД-инструмента на TypeScript возникает ключевой архитектурный вопрос: Как построить единое, строго типизированное, производительное и безопасное приложение, работающее с 15+ реляционными, документоориентированными, key-value, OLAP и встраиваемыми СУБД без единого системного SPI?
В этой статье рассматривается, как данная проблема решена в архитектуре LibreDB Studio с помощью единого слоя DatabaseProvider.
2. Постановка задачи
При проектировании универсального клиента СУБД на TypeScript возникают пять основных архитектурных ограничений:
Разнородность парадигм СУБД: Реляционные БД (
PostgreSQL,MySQL), документоориентированные (MongoDB), Key-Value хранилища (Redis), OLAP-системы (ClickHouse,Trino,Druid) и встраиваемые движки (SQLite,@libredb/libredb) не имеют общего языка запросов или единого жизненного цикла соединений.Накладные расходы и потребление памяти: Статический импорт драйверов для 15+ СУБД при старте приложения приведёт к раздуванию сборки и неадекватному расходу оперативной памяти (RSS).
Нормализация интроспекции схем: Для UI требуется единое дерево объектов (Контейнеры
Папки
Объекты
Колонки/Индексы). Однако PostgreSQL использует
pg_catalog, MySQL —information_schema, SQLite — функцииpragma_*, Redis — префиксы ключей, а встраиваемые движки — собственные реестры каталогов.Безопасность ИИ-агентов: При выполнении сгенерированных SQL-запросов ИИ-агентами архитектура должна гарантировать изоляцию в режиме «только чтение» (Read-Only) на уровне соединения с БД (запрет деструктивных SQL-операций или работы с ФС).
Конкурентность и блокировки: Встраиваемые БД (SQLite или embedded LibreDB) используют эксклюзивные блокировки файлов (
.lock). Попытка открыть несколько параллельных хэндлов к одному файлу приводит к ошибкам или блокировке приложения.
3. Обзор архитектуры: SPI DatabaseProvider и паттерн «Адаптер»
Для решения этой задачи LibreDB Studio реализует строгий паттерн «Адаптер / Стратегия» (Adapter / Strategy Pattern), в основе которого лежит абстрактный контракт BaseDatabaseProvider.

Ключевые архитектурные принципы
Без написания протоколов с нуля: Слой провайдера не переписывает низкоуровневые сетевые TCP-протоколы. Вместо этого он обворачивает проверенные временем npm-пакеты.
Без тяжелых ORM для целевых БД: Запросы к целевым СУБД (просмотр данных, интроспекция, планы выполнения) выполняются через чистый SQL или нативные команды драйвера. Использование ORM (Prisma, Drizzle) исключено для обеспечения максимального контроля над запросами и нулевых накладных расходов.
Единый жизненный цикл: Каждый провайдер реализует стандартизированный контракт: управление пулом, выполнение запросов, интроспекция схем, мониторинг состояния и обслуживание.
4. Разбор ключевых инженерных задач
Задача 1: Динамическая загрузка модулей без лишних расходов памяти
Статический импорт oracledb, cassandra-driver, mysql2, @duckdb/node-api и pg вызовет выделение десятков мегабайт нативной памяти под драйверы, которые пользователь может никогда не открыть.
Решение: Динамический импорт через import() в фабрике провайдеров (Provider Factory).
// src/lib/db/factory.ts export async function createDatabaseProvider( connection: DatabaseConnection, options: ProviderOptions = {}, execution: ProviderExecutionContext = {} ): Promise<DatabaseProvider> { switch (connection.type) { case "postgres": { const { PostgresProvider } = await import("./providers/sql/postgres"); return new PostgresProvider(connection, options, execution); } case "mysql": { const { MySQLProvider } = await import("./providers/sql/mysql"); return new MySQLProvider(connection, options); } case "sqlite": { const { SQLiteProvider } = await import("./providers/sql/sqlite"); return new SQLiteProvider(connection, options, execution); } case "libredb": { const { LibreDBProvider } = await import("./providers/embedded/libredb"); return new LibreDBProvider(connection, options); } default: throw new DatabaseConfigError(`Unsupported database type: ${connection.type}`); } }
Результат: Нативные бинарные модули загружаются в память только тогда, когда пользователь активно подключается к СУБД соответствующего типа.
Задача 2: Унификация разнородных схем СУБД («Object Surface API»)
Различные СУБД хранят метаданные по-разному:
Реляционные (PostgreSQL / MySQL): Многоуровневые иерархии (
DatabaseSchemaTables/Views/Functions/Triggers).Файловые (SQLite): Единая схема (
main), интроспекция через PRAGMA-функции (pragma_table_xinfo,pragma_index_list).Key-Value (Redis): Единое пространство ключей, псевдо-таблицы на основе двоеточий в префиксах (например,
user:*).Встраиваемые (LibreDB): Внутренний Key-Value движок с реестром каталога (
relational,document,keyspace).
Для отрисовки единого дерева в UI LibreDB Studio обязует все провайдеры реализовывать Object Surface API:
export interface DatabaseProvider { listContainers(parent?: readonly string[]): Promise<Container[]>; countObjects(container: readonly string[]): Promise<Record<string, KindCount>>; listObjects(container: readonly string[], kind: string): Promise<DatabaseObject[]>; describeObject(path: readonly string[], kind: string): Promise<ObjectDetail>; describeObjects(container: readonly string[], kind: string, limit?: number): Promise<ObjectDetailBatch>; }
Пример нормализации:
Будь то интроспекция таблицы PostgreSQL через information_schema.columns или коллекции встраиваемой LibreDB через каталог @libredb/libredb, пользовательский интерфейс получает унифицированную структуру ObjectDetail:
export interface ObjectDetail { path: string[]; columns: ColumnSchema[]; indexes: IndexSchema[]; foreignKeys: ForeignKeySchema[]; }
Задача 3: Безопасность ИИ-агентов и профили выполнения в режиме «только чтение»
При выполнении SQL-запросов, сгенерированных ИИ-агентом, использование обычного пула соединений несёт фатальные риски безопасности (например, инъекция DROP TABLE или непреднамеренное изменение данных).
LibreDB Studio вводит Профили выполнения (agent-read-only, agent-operations, agent-handover), обслуживаемые в отдельном кэше провайдеров:
// src/lib/db/factory.ts const profiledProviderCache = new Map<string, ProfiledCachedProvider>(); export async function acquireExecutionProfileProvider( connection: DatabaseConnection, profile: ExecutionProfile, options: ProviderOptions = {} ): Promise<DatabaseProvider> { // 1. Никогда не возвращать соединение из пользовательского пула записи. // 2. Открыть изолированное соединение с профилем read-only. // 3. Применить нативные ограничения СУБД для режима "только чтение". }
Нативная защита на уровне СУБД:
PostgreSQL: Проверяет параметры транзакции
readOnly: trueи ограничение прав роли при открытии соединения.SQLite: Принудительно устанавливает
PRAGMA query_only = trueпри открытии И перед каждым запросом:// src/lib/db/providers/sql/sqlite.ts export function assertQueryOnlyEnabled(readback: unknown[]): void { const value = (readback[0] as { query_only?: unknown })?.query_only; if (value !== 1) { throw new ConnectionError("SQLite read-only profile could not enable query_only", "sqlite"); } }
Задача 4: Блокировки файлов (Single-Writer) и проброс SSH-туннелей
1. Повторное использование хэндла при Single-Writer блокировке
Файловые СУБД (SQLite, @libredb/libredb) берут эксклюзивную блокировку файла (.lock). Попытка открыть второй хэндл к тому же файлу вызовет ошибку LOCKED.
Фабрика провайдеров проверяет флаг singleWriterFile: true и переиспользует активный хэндл для инспекций в режиме чтения, избегая конфликтов блокировок:
export function findOpenSingleWriterProvider(connection: DatabaseConnection): DatabaseProvider | null { const identity = fileIdentity(connection); if (!identity) return null; for (const entry of providerCache.values()) { if (entry.singleWriterFile === identity && entry.provider.isConnected()) { return entry.provider; } } return null; }
2. Прозрачное SSH-туннелирование
Для баз данных за бастион-серверами фабрика автоматически пробрасывает SSH-туннель:
if (connection.sshTunnel?.enabled && connection.host && connection.port) { tunnel = await createSSHTunnel(connection.id, connection.sshTunnel, connection.host, connection.port); effectiveConnection = { ...connection, host: tunnel.localHost, port: tunnel.localPort }; }
5. Разбор кода и детали реализации
Абстрактный контракт провайдера
Ниже приведена сокращенная версия BaseDatabaseProvider ([src/lib/db/base-provider.ts](file:///home/cevheri/projects/libredb/libredb-studio/src/lib/db/base-provider.ts)):
export abstract class BaseDatabaseProvider implements DatabaseProvider { public readonly type: DatabaseType; public readonly config: DatabaseConnection; protected constructor(config: DatabaseConnection, options: ProviderOptions = {}) { this.type = config.type; this.config = config; this.options = options; this.state = { connected: false, activeQueries: 0 }; } public abstract connect(): Promise<void>; public abstract disconnect(): Promise<void>; public abstract query(sql: string, params?: unknown[]): Promise<QueryResult>; public abstract listContainers(parent?: readonly string[]): Promise<Container[]>; public abstract countObjects(container: readonly string[]): Promise<Record<string, KindCount>>; public abstract listObjects(container: readonly string[], kind: string): Promise<DatabaseObject[]>; public abstract describeObject(path: readonly string[], kind: string): Promise<ObjectDetail>; public abstract getOverview(): Promise<DatabaseOverview>; public abstract getPerformanceMetrics(): Promise<PerformanceMetrics>; public abstract getSlowQueries(options?: { limit?: number }): Promise<SlowQueryStats[]>; public abstract getActiveSessions(options?: { limit?: number }): Promise<ActiveSessionDetails[]>; protected redactConnectionString(connectionString: string): string { // Маскирование паролей и токенов в URI строках подключения } }
6. Главные выводы и уроки
Абстракция вместо изобретения велосипеда: Не пытайтесь писать нативные сетевые драйверы СУБД на TypeScript с нуля. Оборачивайте зрелые npm-пакеты (
pg,mysql2,ioredis) в единую абстракцию SPI.Динамический импорт обязателен: Загрузка драйверов через
import()предотвращает задержки при старте и сохраняет минимальный объем используемой памяти.Разделение UI и парадигм СУБД: Единый “Object Surface API” позволяет UI отображать объекты, схемы, индексы и колонки одинаково для SQL, NoSQL, Key-Value и встраиваемых БД.
Безопасность на уровне архитектуры: Разделяйте пулы выполнения ИИ-агентов и пользователей на уровне провайдеров с принудительной установкой нативных флагов read-only (
PRAGMA query_only, ограничение ролей БД).
Данная архитектура лежит в основе LibreDB Studio, позволяя эффективно и безопасно работать с 15+ СУБД в едином TypeScript-приложении.
links:
