Архитектура универсального провайдера баз данных на TypeScript без JDBC

от автора

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 возникают пять основных архитектурных ограничений:

  1. Разнородность парадигм СУБД: Реляционные БД (PostgreSQL, MySQL), документоориентированные (MongoDB), Key-Value хранилища (Redis), OLAP-системы (ClickHouse, Trino, Druid) и встраиваемые движки (SQLite, @libredb/libredb) не имеют общего языка запросов или единого жизненного цикла соединений.

  2. Накладные расходы и потребление памяти: Статический импорт драйверов для 15+ СУБД при старте приложения приведёт к раздуванию сборки и неадекватному расходу оперативной памяти (RSS).

  3. Нормализация интроспекции схем: Для UI требуется единое дерево объектов (Контейнеры \rightarrow Папки \rightarrow Объекты \rightarrow Колонки/Индексы). Однако PostgreSQL использует pg_catalog, MySQL — information_schema, SQLite — функции pragma_*, Redis — префиксы ключей, а встраиваемые движки — собственные реестры каталогов.

  4. Безопасность ИИ-агентов: При выполнении сгенерированных SQL-запросов ИИ-агентами архитектура должна гарантировать изоляцию в режиме «только чтение» (Read-Only) на уровне соединения с БД (запрет деструктивных SQL-операций или работы с ФС).

  5. Конкурентность и блокировки: Встраиваемые БД (SQLite или embedded LibreDB) используют эксклюзивные блокировки файлов (.lock). Попытка открыть несколько параллельных хэндлов к одному файлу приводит к ошибкам или блокировке приложения.


3. Обзор архитектуры: SPI DatabaseProvider и паттерн «Адаптер»

Для решения этой задачи LibreDB Studio реализует строгий паттерн «Адаптер / Стратегия» (Adapter / Strategy Pattern), в основе которого лежит абстрактный контракт BaseDatabaseProvider.

Ключевые архитектурные принципы

  1. Без написания протоколов с нуля: Слой провайдера не переписывает низкоуровневые сетевые TCP-протоколы. Вместо этого он обворачивает проверенные временем npm-пакеты.

  2. Без тяжелых ORM для целевых БД: Запросы к целевым СУБД (просмотр данных, интроспекция, планы выполнения) выполняются через чистый SQL или нативные команды драйвера. Использование ORM (Prisma, Drizzle) исключено для обеспечения максимального контроля над запросами и нулевых накладных расходов.

  3. Единый жизненный цикл: Каждый провайдер реализует стандартизированный контракт: управление пулом, выполнение запросов, интроспекция схем, мониторинг состояния и обслуживание.


4. Разбор ключевых инженерных задач

Задача 1: Динамическая загрузка модулей без лишних расходов памяти

Статический импорт oracledb, cassandra-driver, mysql2, @duckdb/node-api и pg вызовет выделение десятков мегабайт нативной памяти под драйверы, которые пользователь может никогда не открыть.

Решение: Динамический импорт через import() в фабрике провайдеров (Provider Factory).

// src/lib/db/factory.tsexport 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): Многоуровневые иерархии (Database \rightarrow Schema \rightarrow Tables/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.tsconst 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.tsexport 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. Главные выводы и уроки

  1. Абстракция вместо изобретения велосипеда: Не пытайтесь писать нативные сетевые драйверы СУБД на TypeScript с нуля. Оборачивайте зрелые npm-пакеты (pg, mysql2, ioredis) в единую абстракцию SPI.

  2. Динамический импорт обязателен: Загрузка драйверов через import() предотвращает задержки при старте и сохраняет минимальный объем используемой памяти.

  3. Разделение UI и парадигм СУБД: Единый “Object Surface API” позволяет UI отображать объекты, схемы, индексы и колонки одинаково для SQL, NoSQL, Key-Value и встраиваемых БД.

  4. Безопасность на уровне архитектуры: Разделяйте пулы выполнения ИИ-агентов и пользователей на уровне провайдеров с принудительной установкой нативных флагов read-only (PRAGMA query_only, ограничение ролей БД).


Данная архитектура лежит в основе LibreDB Studio, позволяя эффективно и безопасно работать с 15+ СУБД в едином TypeScript-приложении.

links:

https://github.com/libredb/libredb-studio

https://cevheri.medium.com/building-a-universal-database-provider-architecture-in-typescript-without-jdbc-4ac0dfe8b6ff?sharedUserId=cevheri

https://cevheri.substack.com/p/building-a-universal-database-provider?r=yo6du&utm_campaign=post-expanded-share&utm_medium=web

ссылка на оригинал статьи https://habr.com/ru/articles/1081638/