ESPNBen Shelton's shot at history comes Sunday in the US Open finalESPN DeportesMbappé alcanza a Raphinha: así van los goleadores de LaLigaוואלהכביש 6 נחסם? עומסי תנועה חריגים בעקבות רכב שעלה באשNOS SportRybakina troeft Sabalenka weer af in grandslamfinale en wint US OpenLa PresseRosemont–La Petite-Patrie | Un enfant de 4 ans blessé par une voiture à MontréalPremium TimesAbiodun wins National Award for Ogun’s digital transformationNOS13 passagiers busongeluk Zwitserland terug in NederlandWirtualna PolskaOfensywa PiS na wsi. Kaczyński złożył obietnice rolnikomسكاي نيوز عربيةالقوات العراقية تدمر "مضافة" لتنظيم داعش بضربة جوية في ديالىHabertürkAÖL son kayıt tarihiynet ספורטחי מגמר אליפות ארה"ב: ארינה סבאלנקה - ילנה ריבאקינהNDTV SportsAryna Sabalenka vs Elena Rybakina, US Open Women's Singles Final LIVE: Rybakina Breaks Sabalenka Again In Deciding Set, On Verge Of Glory
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.