Skip to content

Diagrams

A diagram-first walk through the whole app, from the outside in: the overall shape, then process model, IPC, the main-process subsystems, the database layer, the plugin system and its security model, the renderer, the AI assistant, the MCP server, and the build. Read architecture.md for the prose; this doc is the picture book that goes with it.

The notifications / attention subsystem has its own diagram-rich doc — see notifications.md. The AI assistant prose lives in ai.md, plugins in plugins.md, the security boundary in plugin-security.md, and IPC in ipc.md.


System landscape. Verql is an Electron app (three processes) that talks out to databases, AI providers, the OS, and accepts inbound connections from MCP clients.

flowchart TB
    user(["User"])
    subgraph App["Verql (Electron app)"]
        direction TB
        main["Main process<br/>Node: windows, IPC, plugins, DB"]
        preload["Preload bridge<br/>window.electronAPI"]
        renderer["Renderer<br/>React 19 SPA"]
        renderer <--> preload
        preload <--> main
    end
    subgraph Ext["External systems"]
        dbs[("Databases<br/>Postgres / MySQL / SQLite<br/>Mongo / Redis / Snowflake")]
        ai["AI providers<br/>OpenAI / Anthropic / Ollama"]
        mcp["MCP clients<br/>e.g. Claude Code"]
        os["OS: keyring, notifications, files"]
    end
    user --> renderer
    main --> dbs
    main --> ai
    mcp --> main
    main --> os

Orchestrator + plugins. The guiding principle: src/main/ is a thin orchestrator (glue), and everything dialect- or format-specific lives in a plugin. The renderer talks to main only through the shared/ contracts.

flowchart TB
    subgraph Renderer["Renderer (React)"]
        Stores["Zustand stores"]
        DS["Primitives design system"]
        Feat["Feature UI: query, explorer, ai, charts"]
    end
    subgraph Shared["shared/ contracts"]
        Ipc["ipc.ts"]
        Types["types.ts / ai-types.ts / settings.ts"]
    end
    subgraph Main["Main process (orchestrator)"]
        IpcH["IPC handlers"]
        Cfg["Config + keyring"]
        Host["Plugin host + SDK"]
        Mcp["MCP server"]
        Att["Attention seam"]
    end
    subgraph Plugins["Bundled plugins (own the domain logic)"]
        Drv["Drivers: sqlite/pg/mysql/mongo/redis/snowflake"]
        AIp["ai"]
        Fmt["core-formats"]
        Thm["core-themes"]
        Osn["os-notifications"]
        Dbt["db-tools"]
    end
    Renderer --> Shared --> Main
    Host --> Plugins
    Main -. registries .- Plugins

The bridge. The renderer has no Node access; its only path to the OS is the preload’s invoke() / on(). Main answers invokes and pushes events.

flowchart LR
    subgraph R["Renderer (Chromium, no Node)"]
        UI["React components"]
        ST["stores"]
    end
    subgraph P["Preload (sandboxed)"]
        API["window.electronAPI<br/>invoke() and on()"]
    end
    subgraph M["Main (Node, full privilege)"]
        H["ipc-handlers"]
    end
    UI --> ST
    ST -->|"invoke(channel, ...args)"| API
    API -->|"ipcRenderer.invoke"| H
    H -->|"return Promise"| API
    H -->|"webContents.send(event)"| API
    API -->|"on(event, cb)"| ST

Two channel kinds. Invoke is request/response; events are one-way broadcasts (streaming, lifecycle).

sequenceDiagram
    autonumber
    participant R as Renderer
    participant B as Preload bridge
    participant H as ipc-handlers
    Note over R,H: Invoke (request / response)
    R->>B: invoke(DB_QUERY, id, sql)
    B->>H: ipcRenderer.invoke
    H-->>B: QueryResult (Promise)
    B-->>R: await result
    Note over R,H: Event (one-way broadcast)
    H->>B: webContents.send(AI_CHAT_EVENT, ...)
    B->>R: on(AI_CHAT_EVENT, cb)

Channel domains. Every channel is a typed constant in shared/ipc.ts, grouped by domain.

mindmap
  root((IPC channels))
    Invoke IPC_CHANNELS
      connections
      db
      export and import
      plugins
      settings
      keyring
      mcp
      migration
      ai
      app
    Events IPC_EVENTS
      ai chat event
      ai explain event
      app action perform
      mcp approval request
      notifications show
      activity event

Subsystem map. index.ts boots the window and ipc-handlers.ts, which wires the subsystems.

flowchart TB
    Index["index.ts<br/>window + bootstrap"]
    IpcH["ipc-handlers.ts"]
    subgraph Sub["Subsystems"]
        Cfg["config/store.ts"]
        Key["keyring.ts"]
        Db["db/ (adapter + factory)"]
        Host["plugins/ (host + SDK)"]
        Mcp["mcp/"]
        Att["attention/"]
        Mig["migration/ and updater/"]
        Act["activity/ log"]
    end
    Index --> IpcH
    IpcH --> Cfg & Key & Db & Host & Mcp & Att & Mig & Act

Config + keyring. Connection secrets are extracted to the OS keyring; the profile written to config.json never contains them.

sequenceDiagram
    autonumber
    participant R as Renderer
    participant H as connections handler
    participant Cfg as Config store
    participant Key as Keyring (safeStorage)
    R->>H: connections:save(profile + secrets)
    H->>Key: store(namespace, secret)
    H->>Cfg: write profile (secrets stripped)
    Cfg->>Cfg: atomic JSON write to disk
    Note over Cfg,Key: secrets never touch config.json
    R->>H: connections:connect(id)
    H->>Cfg: read profile
    H->>Key: retrieve(secret)
    H->>H: createAdapter(profile + secret)

The contract. Every driver implements DbAdapter; a DriverFactory (registered with the DriverRegistry) creates one. createAdapter resolves a profile’s adapter purely through the registry — no special-cased built-ins.

classDiagram
    class DbAdapter {
        +connect()
        +query(sql, params, opts)
        +getTables(schema)
        +getColumns(table, schema)
        +getSchemas()
        +openSession(id, opts)
        +beginTransaction(id)
        +commit(id)
        +rollback(id)
    }
    class DriverFactory {
        +createAdapter(config)
        +sqlDialect
        +quoteChar
        +placeholderStyle
        +sampleQuery(table)
    }
    class DriverRegistry {
        +register(id, factory)
        +get(id)
    }
    class PostgresAdapter {
        +query()
    }
    class MysqlAdapter {
        +query()
    }
    class SqliteAdapter {
        +query()
    }
    DriverRegistry --> DriverFactory : holds
    DriverFactory --> DbAdapter : creates
    DbAdapter <|-- PostgresAdapter
    DbAdapter <|-- MysqlAdapter
    DbAdapter <|-- SqliteAdapter

Running a query (data flow). Renderer → preload → ipc/db.ts → adapter → database, and back; the result lands on the active tab and AG Grid renders it.

sequenceDiagram
    autonumber
    actor User
    participant QP as QueryPanel (renderer)
    participant B as Preload
    participant DBH as ipc/db.ts
    participant AD as DbAdapter
    participant DB as Database
    User->>QP: run SQL
    QP->>B: invoke(DB_QUERY, profileId, sql, opts)
    B->>DBH: handle
    DBH->>AD: query(sql, params, opts)
    AD->>DB: execute
    DB-->>AD: rows
    AD-->>DBH: QueryResult
    DBH-->>QP: QueryResult
    QP->>QP: tabs store then AG Grid renders
    DBH->>B: activity:event (broadcast)

Connection middleware. A plugin can wrap connect/disconnect — e.g. the ssh-tunnel plugin opens a tunnel and rewrites the profile to a local port.

sequenceDiagram
    autonumber
    participant H as connections:connect
    participant MW as Middleware (ssh-tunnel)
    participant F as createAdapter
    participant AD as DbAdapter
    H->>MW: shouldApply(profile)?
    MW-->>H: true
    H->>MW: beforeConnect(profile)
    MW->>MW: open SSH tunnel to local port
    MW-->>H: rewritten profile (127.0.0.1:port)
    H->>F: createAdapter(profile)
    F-->>H: adapter
    H->>AD: connect()
    Note over H: on disconnect
    H->>MW: onDisconnect(profileId) closes tunnel

Connection state.

stateDiagram-v2
    [*] --> Disconnected
    Disconnected --> Connecting : connect()
    Connecting --> Connected : adapter.connect ok
    Connecting --> Failed : error
    Failed --> Connecting : retry
    Connected --> Disconnected : disconnect()
    Connected --> Querying : query()
    Querying --> Connected : result or error
    Disconnected --> [*]

Boot pipeline. Five phases; what a manifest declares must actually be registered or the plugin lands in degraded.

flowchart LR
    A["Discover<br/>scan dirs + manifests"] --> B["Validate<br/>validateManifest"]
    B --> C["Resolve<br/>(deps passthrough)"]
    C --> D["Activate<br/>module.activate(ctx), 10s timeout"]
    D --> E["Verify<br/>registered vs contributes"]
    B -. fail .-> X1["error: validate"]
    D -. fail .-> X2["error: activate"]
    E -->|"all present"| OK["active"]
    E -->|"some missing"| DEG["degraded"]

Plugin state machine. Including user enable/disable and the error budget auto-deactivation.

stateDiagram-v2
    [*] --> Discovered
    Discovered --> Validated : manifest ok
    Discovered --> Error : invalid manifest
    Validated --> Active : activate + verify ok
    Validated --> Degraded : missing contributions
    Validated --> Error : activate threw or timeout
    Active --> Inactive : user disables
    Degraded --> Inactive : user disables
    Inactive --> Active : user enables
    Active --> Error : error budget tripped
    Error --> [*]

Contribution surfaces. Everything a plugin can register, via PluginContext.

mindmap
  root((PluginContext))
    Data
      drivers
      typeMappers
      schema
      connections
    Formats
      exporters
      importers
      formatters
    UI
      panels
      ui widgets
      commands
      completions
      themes
      dragDrop
    AI
      providers
      tools
      contextProviders
    Platform
      keyring
      ipc
      broadcast
      services
      settings
      notifications

Host structure. The BootCoordinator tracks a record per plugin and builds a guarded PluginContext that writes into the shared SDK registries.

classDiagram
    class BootCoordinator {
        +discover() void
        +validate() void
        +activate() void
        +verify() void
    }
    class PluginRecord {
        +PluginManifest manifest
        +string state
        +string path
        +bool trusted
    }
    class PluginContext {
        +DriverRegistry drivers
        +ToolRegistry tools
        +SchemaAccess schema
        +ConnectionAccess connections
        +KeyringAccess keyring
        +ServiceRegistry services
        +BroadcastFn broadcast
    }
    BootCoordinator o-- PluginRecord : tracks
    BootCoordinator ..> PluginContext : builds per plugin

Trust boundary. Bundled = trusted (in-process, all caps). Third-party = untrusted; isolatable contributions run in a utilityProcess, the rest run in-process behind the enforced gates.

flowchart TD
    P["Plugin loaded"] --> Q{"path == bundled?"}
    Q -- yes --> T["Trusted<br/>all capabilities granted"]
    Q -- no --> U["Untrusted<br/>deny-by-default"]
    U --> I{"canIsolate(manifest)?"}
    I -- yes --> W["Run in utilityProcess<br/>RPC bridge + module sandbox"]
    I -- no --> H["Run in-process<br/>enforced gates only"]
    W --> G["Capability call to host guard to grant check"]
    H --> G

Capability gate. A sensitive call is checked against effectiveGrants (manifest ∩ user grant) before the host answers.

sequenceDiagram
    autonumber
    participant Pl as Plugin (untrusted)
    participant Ctx as Guarded PluginContext
    participant Grant as effectiveGrants
    participant Host as Host capability (keyring)
    Pl->>Ctx: keyring.retrieve(ns, key)
    Ctx->>Grant: has(keyring)?
    alt granted (manifest and user grant)
        Grant-->>Ctx: true
        Ctx->>Host: retrieve
        Host-->>Pl: secret
    else not granted
        Grant-->>Ctx: false
        Ctx-->>Pl: throw PermissionDeniedError
    end

Process isolation. The worker runs the plugin behind a module sandbox; its only path to a Verql capability is RPC the host answers through the same guarded context the in-process path uses.

flowchart LR
    subgraph Host["Main process (host)"]
        IP["isolated-plugin.ts<br/>controller + proxies"]
        Reg["Real SDK registries"]
        GCtx["Guarded PluginContext"]
    end
    subgraph Worker["utilityProcess (worker)"]
        WR["worker-runtime"]
        SB["module sandbox<br/>gates net/fs/child_process"]
        Pl["third-party plugin"]
    end
    Pl --> SB
    Pl -->|"proxy ctx call"| WR
    WR <-->|"RPC (protocol.ts)"| IP
    IP --> GCtx
    GCtx --> Reg
    IP -->|"register proxies"| Reg

Permission grant lifecycle.

stateDiagram-v2
    [*] --> Declared : manifest.permissions
    Declared --> Ungranted : install (zero grants)
    Ungranted --> Granted : user grants in Permissions tab
    Granted --> Effective : grant and manifest, read at activation
    Granted --> Ungranted : user revokes
    Effective --> Ungranted : permission dropped from manifest
    Effective --> [*]

Store map. Zustand stores hold the app state; some (editor, tab-actions) are non-reactive ref registries.

flowchart TB
    subgraph Stores["Zustand stores"]
        conn["connections"]
        tabs["tabs"]
        schema["schema"]
        ui["ui"]
        ai["ai"]
        sel["selection"]
        notif["notifications"]
        toast["toast"]
        dcap["driver-capabilities"]
        themes["themes"]
        settings["settings"]
        editor["editor (refs)"]
        tactions["tab-actions (refs)"]
    end
    conn --> tabs
    conn --> schema
    tabs --> editor
    tabs --> tactions
    schema --> sel

Tabs. The open-tab state is a discriminated union.

classDiagram
    class Tab {
        +string id
        +string type
    }
    class QueryTab {
        +string sql
        +QueryResult result
    }
    class TableTab {
        +string table
        +string schema
    }
    class ErDiagramTab {
        +string connectionId
    }
    class ConnectionFormTab {
        +string profileId
    }
    class PluginDetailTab {
        +string pluginName
    }
    class InstallPluginTab {
        +string source
    }
    class SettingsTab {
        +string category
    }
    Tab <|-- QueryTab
    Tab <|-- TableTab
    Tab <|-- ErDiagramTab
    Tab <|-- ConnectionFormTab
    Tab <|-- PluginDetailTab
    Tab <|-- InstallPluginTab
    Tab <|-- SettingsTab

Theming. Three token layers; the active theme remaps the semantic layer via a data-theme attribute, and theme plugins override the raw scale.

flowchart LR
    Raw["Raw color scale<br/>--color-* primitives"] --> Sem["Semantic tokens<br/>remapped per theme"]
    Sem --> Comp["Component tokens"]
    Comp --> CVA["CVA variants on primitives"]
    Theme["data-theme attribute<br/>ThemeProvider"] -. selects .-> Sem
    Plug["Theme plugin overrides"] -. layer .-> Raw

Design system & key libraries.

mindmap
  root((Design system))
    primitives
      forms
      layout
      surfaces
      data-display
      feedback
      navigation
      typography
    theming
      raw scale
      semantic
      component
      themes dark light midnight
    key libs
      Monaco SQL editor
      AG Grid results
      xyflow ER diagram
      Recharts charts

Architecture. The assistant is a bundled plugin. The renderer owns the chat UI and the App-Action registry; main owns the providers, the tool loop, and permission gating. The ToolRegistry is shared with the MCP server.

flowchart TB
    subgraph RendererAI["Renderer"]
        Chat["ChatPanel / stores/ai.ts"]
        AA["App-Action registry"]
    end
    subgraph MainAI["ai plugin (main)"]
        CM["ConversationManager"]
        PR["ProviderRegistry"]
        PM["PermissionManager"]
        EN["Enhancements"]
    end
    TR["Shared ToolRegistry"]
    Prov["OpenAI / Anthropic / Ollama"]
    MCPs["MCP server"]
    Chat -->|"ai:chat:start"| CM
    CM --> PR
    PR --> Prov
    CM --> TR
    MCPs --> TR
    CM -->|"approval"| PM
    CM -->|"ai:chat:event"| Chat
    CM -->|"app:action:perform"| AA
    AA -->|"app:action:result"| CM

A chat turn. Assemble + budget, then a stream/tool loop up to MAX_TOOL_ROUNDS.

sequenceDiagram
    autonumber
    actor User
    participant UI as ChatInput / stores/ai
    participant CM as ConversationManager
    participant P as Provider
    participant TR as ToolRegistry
    User->>UI: send message
    UI->>CM: ai:chat:start(message, connectionId, appActions)
    CM->>CM: assemble system prompt + trim to budget
    loop up to MAX_TOOL_ROUNDS = 10
        CM->>P: chat(request) stream
        P-->>CM: text chunks then ai:chat:event
        P-->>CM: tool-call
        opt write tool
            CM->>UI: approval-request
            UI-->>CM: approval-response
        end
        CM->>TR: execute(id, params, ctx)
        TR-->>CM: ToolResult
    end
    CM-->>UI: done then persist conversation

The tool loop, as a decision.

flowchart TD
    S["provider chunk"] --> T{"chunk type?"}
    T -- text --> TX["forward as ai:chat:event"]
    T -- tool-call --> R["resolve tool"]
    R --> W{"write tool?"}
    W -- yes --> AP{"approved?"}
    AP -- no --> REJ["return rejection to model"]
    AP -- yes --> EX["toolRegistry.execute"]
    W -- no --> EX
    EX --> FB["feed result back"]
    T -- done --> END["finish turn"]
    FB --> S
    TX --> S

App actions: one registry, two surfaces. A user-clicked deep-link chip, and an AI-initiated tool (navigation only) that round-trips to the renderer.

flowchart TB
    Reg["AppAction registry (renderer)"]
    subgraph S1["Deep-link chip (user-clicked)"]
        MD["verql://action/id in markdown"] --> Chip["ActionChip"]
        Chip --> Run1["run(params)"]
    end
    subgraph S2["Agentic tool (AI-initiated)"]
        Tool["perform_app_action (main)"] -->|"app:action:perform"| Bridge["renderer bridge"]
        Bridge -->|"navigation only"| Run2["run(params)"]
        Bridge -->|"app:action:result"| Tool
    end
    Reg --> S1
    Reg --> S2
    Run1 -. mutating confirms .-> Reg

Conversation history (renderer-owned, persisted to localStorage).

erDiagram
    CONVERSATION {
        string id PK
        string title
        number createdAt
        number updatedAt
    }
    MESSAGE {
        string id PK
        string role "user-assistant-tool"
        string content
        number timestamp
    }
    SESSION_STATS {
        number tokens
        number toolCalls
    }
    CONVERSATION ||--o{ MESSAGE : contains
    CONVERSATION ||--|| SESSION_STATS : tracks

Exposes the same ToolRegistry to external MCP clients over a tokenised SSE endpoint, with the same per-tool gating and write-approval the AI chat uses. (Approval flow diagram: notifications.md.)

flowchart LR
    Client["External MCP client<br/>Claude Code"] -->|"SSE + bearer token"| Srv["MCP server (http)"]
    Srv --> Gate["gate: disabledTools + readOnly"]
    Srv --> TR["shared ToolRegistry"]
    Srv -->|"write tool"| Appr["approval: renderer + attention"]
    Srv --> Act["activity log"]
    TR --> Tools["db-tools and others"]

electron-vite builds three targets; native modules are externalized and rebuilt for Electron’s ABI; electron-builder packages per platform.

flowchart LR
    Src["src main, preload, renderer + shared"] --> EV["electron-vite build"]
    EV --> OutMain["out/main"]
    EV --> OutPre["out/preload"]
    EV --> OutRen["out/renderer"]
    OutMain & OutPre & OutRen --> EB["electron-builder"]
    EB --> Mac["macOS .dmg"]
    EB --> Win["Windows NSIS"]
    EB --> Lin["Linux AppImage"]
    Native["better-sqlite3 / pg / mysql2<br/>externalized + rebuilt"] -.-> EV