Adding a database engine

SeeKi only speaks PostgreSQL today. The code is shaped so that the second engine — SQLite, MySQL, whatever is next — lands in three small places, and the handlers in src/api/ never notice. This page is the path.

Read Architecture first if you have not. Everything below assumes you know what DatabasePool is and why it is an enum.

The contract

An engine is a module under src/db/ that exposes the four functions the pool dispatches to. The full shape in src/db/mod.rs:

impl DatabasePool {
    pub async fn list_tables(&self) -> anyhow::Result<Vec<TableInfo>>;
    pub async fn get_columns(&self, table: &str) -> anyhow::Result<Vec<ColumnInfo>>;
    pub async fn query_rows(&self, params: RowQueryParams<'_>)
        -> Result<QueryResult, EngineError>;
    pub async fn export_csv(&self, params: ExportQueryParams<'_>)
        -> Result<impl Stream<Item = ...>, EngineError>;
}

Mirror src/db/postgres.rs. It is the reference implementation and, today, the only one.

The walk-through

Assume the new engine is SQLite. Adapt the names as needed.

  1. Declare the variant

    Add Sqlite to DatabaseKind in src/config.rs. The #[serde(rename_all = "lowercase")] attribute maps it to the string "sqlite" in seeki.toml.

    #[derive(Debug, Clone, Copy, Deserialize)]
    #[serde(rename_all = "lowercase")]
    pub enum DatabaseKind {
        Postgres,
        Sqlite,
    }
  2. Create the engine module

    Add src/db/sqlite.rs and register it in src/db/mod.rs:

    pub mod postgres;
    pub mod sqlite;

    The module exports four functions: list_tables, get_columns, query_rows, export_csv. Copy the signatures from src/db/postgres.rs and replace the body.

  3. Add the pool variant

    Extend the enum in src/db/mod.rs with a variant that carries whatever connection handle your driver uses:

    pub enum DatabasePool {
        Postgres(sqlx::PgPool, Option<crate::ssh::SshTunnel>),
        Sqlite(sqlx::SqlitePool),
    }

    SQLite has no network endpoint, so there is no tunnel. An engine that does (MySQL, MSSQL) should keep the Option<SshTunnel> slot.

  4. Wire connect

    Add a match arm in DatabasePool::connect that reads the URL, opens a pool, and returns the new variant. Keep the PostgreSQL arm untouched.

    match config.kind {
        DatabaseKind::Postgres => { /* existing */ }
        DatabaseKind::Sqlite => {
            let pool = sqlx::sqlite::SqlitePoolOptions::new()
                .max_connections(config.max_connections)
                .connect(&config.url)
                .await?;
            Ok(Self::Sqlite(pool))
        }
    }
  5. Wire the dispatch arms

    Every method on DatabasePool is a match. Add an arm for each. The compiler will tell you which ones you missed — the enum is not marked #[non_exhaustive] on purpose.

    pub async fn list_tables(&self) -> anyhow::Result<Vec<TableInfo>> {
        match self {
            Self::Postgres(pool, _) => postgres::list_tables(pool).await,
            Self::Sqlite(pool)      => sqlite::list_tables(pool).await,
        }
    }
  6. Teach the setup flow

    The first-run wizard tests a connection before writing seeki.toml. Add a test_connection entry for the new engine in src/db/sqlite.rs and dispatch to it from src/api/setup.rs. The return type is Vec<TablePreview>, same as PostgreSQL.

  7. Surface it in the UI

    Step 1 of the setup wizard lets the user pick an engine. Add the new option in frontend/src/components/SetupStep1Connection.svelte, and widen the type unions in frontend/src/lib/types.ts. The placeholder for the URL field should match what the driver expects — for SQLite, sqlite://./data.db.

  8. Cover it with tests

    Add engine-level tests in src/db/sqlite.rs against an in-memory or temp-file database. Then extend frontend/tests/ (Playwright) with a spec that boots the binary pointed at a fixture database and walks the grid. See the Justfile target just test-e2e.

Safety rules

Every engine lives behind the same promise the product makes to the user: read-only, and never vulnerable to the data it reads. Two rules, enforced per engine.

1. Parameterise every value

All user-supplied values — filter terms, search strings, pagination bounds — go through bound parameters. Never format them into SQL with format! or string concatenation. The PostgreSQL engine uses sqlx::query(...).bind(value); use the equivalent in your driver.

2. Whitelist every identifier

Table names, column names, and sort directions cannot be parameter-bound — they are part of the SQL grammar. Every identifier that reaches the engine must be validated against the live schema before it is quoted into a query:

  • Take the list of real tables from list_tables, or real columns from get_columns.
  • Reject anything not in that list with ValidationError (the API layer maps this to 400).
  • Quote the survivor using the engine’s identifier quote — double quotes for PostgreSQL and SQLite, backticks for MySQL — and escape the quote character by doubling it.
  • Sort direction accepts only asc or desc, compared case-insensitively, mapped to a fixed literal.

3. Read-only means read-only

No engine function emits INSERT, UPDATE, DELETE, TRUNCATE, DROP, or ALTER. If the driver supports read-only connections natively (PostgreSQL default_transaction_read_only, SQLite URI flag ?mode=ro), enable it. Belt-and-braces is the whole point.

The checklist

FileChange
src/config.rsAdd a variant to DatabaseKind.
src/db/mod.rsDeclare the module, add the pool variant, add arms to each match.
src/db/<engine>.rsImplement list_tables, get_columns, query_rows, export_csv, test_connection.
src/api/setup.rsDispatch /api/setup/test-connection to the new engine.
frontend/src/components/SetupStep1Connection.svelteAdd the engine option and its URL placeholder.
frontend/src/lib/types.tsExtend the engine union.
frontend/tests/Add an end-to-end spec against a fixture database.

Where to go from here

  • Architecture — the big picture the pool fits into.
  • Build & release — once the engine compiles, how to ship it.
  • Rows API — the exact request shape your engine has to satisfy.