The Daily Newsstand · Free, Always
Saturday, September 12, 2026

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

Translate

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 Папки Объекты Колонки/Индексы). Однако 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.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): Многоуровневые иерархии (Database Schema 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.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. Главные выводы и уроки

  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

View the original on Хабр

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.