Skip to content

Architecture

Verql is a desktop database client built on Electron + React. The guiding principle is orchestrator + plugins: src/main/ is a thin orchestrator (windows, IPC, plugin host, config), and everything dialect- or format-specific — database drivers, import/export formats, themes, the AI assistant — lives in a plugin under src/main/plugins/bundled/. Adding a database type or file format means writing a plugin, not editing the core.

Prefer pictures? diagrams.md is a diagram-first visual tour of every subsystem below (context, components, sequences, state machines, class and data models) — a companion to this prose.

Standard three-layer Electron split:

LayerLives inRuntimeTrust
mainsrc/main/Node.jsfull (filesystem, network, native drivers)
preloadsrc/preload/sandboxed bridgeexposes a single typed window.electronAPI
renderersrc/renderer/src/Chromium + React 19no Node access; talks only through the bridge

src/preload/index.ts exposes exactly two methods — invoke<K>(channel, ...args) (request/response) and on(channel, cb) (subscribe to a push) — both typed by the channel map. The renderer has no other way to reach the OS.

shared/ holds the TypeScript contracts both processes import:

FileContract
ipc.tsevery IPC channel + event, as a typed map and SCREAMING_SNAKE_CASE constants
types.tsdomain types: ConnectionProfile, QueryResult, Tab, schema metadata
ai-types.tschat messages, stream events, the chat-start request
settings.tsAppSettings shape + defaults (+ centralized KEYBINDING_ACTION ids)
driver-capabilities.tsserializable driver capability flags — incl. statementSyntax, errorRules, plan/session/explain support
db-errors.tsDbErrorCode taxonomy + serializable DbErrorRule (driver-contributed error classification)
i18n/the message catalogue + framework-free t() / MessageKey (see i18n.md)
mcp.ts, plugin-ui-types.ts, appdata.tsMCP types, plugin UI contracts, app-data-store record types

All renderer↔main traffic goes through ipc.ts. The constant is mandatory at every call site (a CI test rejects string-literal invoke/on calls). See ipc.md for how to add a channel.

src/main/index.ts creates the window and bootstraps the subsystems. ipc-handlers.ts registers handlers, delegating by domain to files under src/main/ipc/ (connections.ts, db.ts, export-import.ts, plugins.ts, settings.ts, mcp.ts, …).

SubsystemWhereNotes
Config storeconfig/store.tsone JSON file (connections + settings), atomic writes; connection secrets are extracted to the OS keyring, never written to disk
Keyringkeyring.tssafeStorage-backed secret storage, namespaced
Database adaptersdb/just the DbAdapter interface + a factory.ts shim; every driver (sqlite/postgresql/mysql included) is a plugin that registers with the SDK DriverRegistry, and createAdapter resolves through it
Plugin hostplugins/discovers, validates, activates plugins; see below
MCP servermcp/exposes the shared tool registry to external MCP clients (e.g. Claude Code) over an approved, tokenised endpoint
Attention seamattention/a delivery-agnostic relay for “the user’s response is needed” (approval prompts, alerts). Producers (AI/MCP approvals) call request/resolve; it’s provided as the attention service so a plugin can surface it. The bundled os-notifications plugin owns the delivery (native OS notifications). Full detail + diagrams: notifications.md.
Migration / updatermigration/, updater/schema-import migrations and auto-update

DbAdapter (db/adapter.ts) is the contract every driver implements: connect / query / introspect (getTables, getColumns, getSchemas, …) plus optional session/transaction methods (openSession, beginTransaction, commit, …) for drivers that support manual transactions, and an optional parseQueryPlan(result) so EXPLAIN parsing stays in the driver.

Drivers own their dialect knowledge end-to-end — the renderer never branches on db type. Beyond SQL generation, a driver declares serializable capabilities the renderer consumes generically: statementSyntax (which statement splitter the CodeLens gutter uses), errorRules (regexes that classify query errors — messages stay in the renderer’s i18n catalogue), explain/session support, and parseQueryPlan for plan trees. This is enforced by the export-import-no-hardcoding test, which fails if a db-type literal/branch reappears in the orchestrator or the key renderer files. See proposals/db-boundary-renderer-migration.md.

Per-query timeouts (opts.timeoutMs) are honoured by the pooled SQL drivers: Postgres sets a server-side statement_timeout on a dedicated client, MySQL uses mysql2’s per-query timeout.

Known limitation — SQLite runs synchronously on the main thread. The sqlite plugin uses better-sqlite3, whose API is synchronous by design, and the adapter runs in the main process. A long-running query against a large SQLite file therefore blocks the main process — freezing the UI and all IPC until it returns — and a per-query timeout can’t be enforced for SQLite (a JS timer can’t fire mid-statement on the blocked thread, and interrupt() would have to come from another thread). Snowflake’s queryTimeout is likewise not yet applied. The fix is to move the SQLite engine into a worker (the build already ships a second main-process worker entry — plugin-worker via utilityProcess — so the pattern is proven); it’s deferred because the production worker-spawn + packaged native-module path needs verification in a real Electron build. Tracked as a follow-up.

A React 19 SPA. Two pillars: Zustand stores for state and a CVA-based primitives design system for UI.

Stores (src/renderer/src/stores/):

StoreOwns
connections.tsconnection profiles, connect/disconnect lifecycle, active connection
tabs.tsopen tabs (a discriminated union: query / table / ER-diagram / connection-form / plugin-detail / install-plugin / settings)
schema.tscached schema metadata keyed by connection + schema
ui.tspanel layout, sidebars, bottom dock, settings category (persisted)
ai.tsAI chat: messages, providers/models, conversations + history
editor.ts, tab-actions.tsnon-reactive registries of mounted Monaco editors / per-tab save+txn handlers
query-history.tsrecorded query runs (mirror of the SQLite app-data query_history table), capped to general.maxHistoryItems
tab-persistence.tsdebounced localStorage snapshot of open query tabs, restored on startup when general.restoreTabsOnStartup is on
selection.ts, notifications.ts, toast.tsinspector selection, the notification center, transient toasts
driver-capabilities.ts, themes.ts, settings.tscapability flags, theme list, settings mirror
plugin-*.tsplugin-contributed commands / panels / lifecycle

Settings (general.*, appearance.*, editor.*, …) flow UI → settings store → settings:set IPC → ConfigStore (atomic JSON, secrets to keyring) → broadcast back; every setting is consumed somewhere (editor options, result formatting, query history, tab restore, keybindings). Full pipeline + per-setting consumers: settings.md.

Internationalization. All user-facing strings resolve through t() from the cross-process catalogue in shared/i18n; the renderer wraps it with <I18nProvider> / useTranslation (locale synced from general.language). See i18n.md.

Design system (src/renderer/src/primitives/) is organised by category (forms/, layout/, surfaces/, data-display/, …) and styled with class-variance-authority. Theming is three layers in primitives/theme/tokens.css (raw color scale → semantic tokens remapped per theme → component tokens), applied via a data-theme attribute by ThemeProvider.

Key libraries: Monaco (SQL editor, custom completion in lib/monaco-sql.ts), AG Grid (results), @xyflow/react (ER diagrams), Recharts (chart panel).

Query editor. The query editor renders per-statement actions through a StatementGutter overlay rather than Monaco’s built-in CodeLens. The splitter + lens actions are keyed by statement syntax ('sql' / 'redis' / 'mongodb'), which each driver declares via its statementSyntax capability — the renderer resolves the syntax from capabilities and looks up the matching contribution (no hardcoded db-type list). The gutter owns the view-zone + content-widget lifecycle and reads execution results from the statement-status store to show a per-statement chip (last run duration, row count, error).

The orchestrator’s extension mechanism. A plugin is a directory with a declarative manifest.json (contributes block) and an activate(ctx) that registers contributions through the SDK. Lifecycle: discover → validate → resolve → activate → runtime, managed by the BootCoordinator in plugins/plugin-host.ts; what’s declared in the manifest must actually be registered or the plugin lands in a degraded state.

Contribution surfaces include drivers, exporters, importers, formatters, type mappers, themes, panels, commands, AI providers, connection middleware, and connection fields. (Query formatting is plugin-owned and keyed by editor language: SQL drivers register a dialect formatter via the shared formatSql helper, MongoDB a JSON one via formatJson, Redis tidies its plaintext command buffer; the main app only resolves and invokes them over db:format-query.) The SDK (plugins/sdk/) provides the registries (DriverRegistry, ToolRegistry, CommandRegistry, PanelRegistry, …) and access objects (SchemaAccess, ConnectionAccess, PluginSettings) via the PluginContext. Bundled plugins live in plugins/bundled/ (sqlite, postgresql, mysql, mongodb, redis, snowflake, db-tools, ai, core-formats, core-themes, ssh-tunnel, os-notifications). Full guide: plugins.md.

The assistant is itself a bundled plugin. It registers AI providers and tools through the SDK, shares one ToolRegistry with the MCP server, and can both guide the user (deep-link chips) and act on the UI (an agentic App-Action bridge). It keeps a persisted, branchable conversation history and trims each request to a token budget. This is a subsystem in its own right — see ai.md.

Running a query. QueryPanel (renderer) calls invoke(DB_QUERY, profileId, sql, …) → preload → ipc/db.ts handler → the profile’s DbAdapter.query()QueryResult returned back up the same path → tabs.ts stores it on the active QueryTab → AG Grid renders it, and the bottom dock offers chart / plan tabs.

An AI chat turn. ChatInputuseAIStore.sendMessage()invoke(AI_CHAT_START, { message, connectionId, appActionsCatalog, … }) → the AI plugin’s ConversationManager.chat() assembles the system prompt, trims context to budget, and streams from the provider. Text arrives as ai:chat:event broadcasts; tool calls run through the shared ToolRegistry (with approval for write tools); perform_app_action tool calls round-trip to the renderer to drive the UI. The renderer accumulates the turn into the active conversation and persists it. Full detail in ai.md.

  • Build: electron-vite with three targets (main / preload / renderer) in electron.vite.config.ts. Native modules (better-sqlite3, pg, mysql2) are externalised from the bundle. Path aliases: @sharedshared/, @src/renderer/src/.
  • Packaging: electron-builder.yml (macOS .dmg, Windows NSIS, Linux AppImage).
  • Testing: Vitest with two projects (vitest.config.ts) — unit (jsdom, tests/unit/) and storybook (Playwright browser, validates stories + accessibility). better-sqlite3 is rebuilt for Electron’s ABI on install, so the SQLite adapter unit tests need pnpm rebuild better-sqlite3 to run under system Node (see the README).
  • Versioning: Changesets — every user-visible change adds one.
shared/ Cross-process TypeScript contracts (ipc, types, ai-types, settings)
src/
├── main/ Orchestrator (Node)
│ ├── index.ts Window + bootstrap
│ ├── ipc-handlers.ts Handler registration
│ ├── ipc/ Handlers by domain (db, connections, export-import, …)
│ ├── config/ Settings + connection store
│ ├── db/ DbAdapter interface + factory shim (drivers are plugins)
│ ├── mcp/ MCP server
│ ├── keyring.ts OS secret storage
│ └── plugins/ Plugin host + SDK + bundled plugins
├── preload/ Typed window.electronAPI bridge
└── renderer/src/ React SPA
├── stores/ Zustand state
├── primitives/ CVA design system + theming
├── components/ Feature UI (query, explorer, ai, charts, results, …)
└── lib/ monaco-sql, app-actions, helpers
docs/ This documentation