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.
-
Declare the variant
Add
SqlitetoDatabaseKindinsrc/config.rs. The#[serde(rename_all = "lowercase")]attribute maps it to the string"sqlite"inseeki.toml.#[derive(Debug, Clone, Copy, Deserialize)] #[serde(rename_all = "lowercase")] pub enum DatabaseKind { Postgres, Sqlite, } -
Create the engine module
Add
src/db/sqlite.rsand register it insrc/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 fromsrc/db/postgres.rsand replace the body. -
Add the pool variant
Extend the enum in
src/db/mod.rswith 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. -
Wire
connectAdd a
matcharm inDatabasePool::connectthat 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)) } } -
Wire the dispatch arms
Every method on
DatabasePoolis amatch. 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, } } -
Teach the setup flow
The first-run wizard tests a connection before writing
seeki.toml. Add atest_connectionentry for the new engine insrc/db/sqlite.rsand dispatch to it fromsrc/api/setup.rs. The return type isVec<TablePreview>, same as PostgreSQL. -
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 infrontend/src/lib/types.ts. The placeholder for the URL field should match what the driver expects — for SQLite,sqlite://./data.db. -
Cover it with tests
Add engine-level tests in
src/db/sqlite.rsagainst an in-memory or temp-file database. Then extendfrontend/tests/(Playwright) with a spec that boots the binary pointed at a fixture database and walks the grid. See the Justfile targetjust 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 fromget_columns. - Reject anything not in that list with
ValidationError(the API layer maps this to400). - 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
ascordesc, 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
| File | Change |
|---|---|
src/config.rs | Add a variant to DatabaseKind. |
src/db/mod.rs | Declare the module, add the pool variant, add arms to each match. |
src/db/<engine>.rs | Implement list_tables, get_columns, query_rows, export_csv, test_connection. |
src/api/setup.rs | Dispatch /api/setup/test-connection to the new engine. |
frontend/src/components/SetupStep1Connection.svelte | Add the engine option and its URL placeholder. |
frontend/src/lib/types.ts | Extend 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.