This file provides guidance to coding agents when working with code in this repository.
# Install dependencies
bun install
# Development (starts services through Portless)
bun run dev
# Build all packages
bun run build
# Run all tests
bun run test
# Lint & format (Biome)
bun run format
# Type checking
bun run typecheck
# Initialize database schema
bun run init-db:pgsql # PostgreSQL
bun run init-db:mysql # MySQL
bun run init-db:mssql # SQL Server
bun run init-db:mongo # MongoDB
bun run init-db:sqlite # SQLite
bun run init-db:libsql # libSQL server (sqld) on OrbStack
bun run init-db:d1 # Cloudflare D1 (remote, via wrangler; D1_DATABASE, default db-studio-e2e)
bun run init-db:redis # Redis
bun run init-db:duckdb # DuckDB
bun run init-db:oracle # Oracle
bun run init-db:clickhouse # ClickHousecd packages/server
bun run test # all tests
bun run test:watch # watch mode
bun run test:coverage # with coverage
bunx vitest run tests/path/to/file.test.ts # single fileDev URLs: Frontend (Vite) →
https://web.db-studio.localhost, API →https://api.db-studio.localhost, proxy →https://proxy.db-studio.localhost, docs →https://www.db-studio.localhost. Production-style local serving useshttps://db-studio.localhostvia the server packagestartscript.
This is a Bun + Turbo monorepo with these packages:
| Package | Role |
|---|---|
packages/server |
Elysia API server on Node (@elysiajs/node) + CLI (npx db-studio) |
packages/web |
React 19 web app (Vite, TanStack Router/Query/Table) |
packages/shared |
Shared types and constants |
packages/proxy |
Cloudflare Workers proxy (rate limiting via Upstash Redis) |
packages/desktop |
Electron desktop shell: starts the server CLI as a child process and opens its web app |
www |
Marketing/docs site (TanStack Start + Fumadocs, deploys to Cloudflare) |
- CLI entry:
src/index.ts— usescommanderto parse flags (--env,--port,--database-url, etc.) - Elysia app:
src/utils/create-server.ts— creates the app, registers adapters, mounts routes, and serves the frontend build..wrap()runs every request inside its request context (request ID, Sentry span,server_operationevent);.onError()hands every failure tohandleErrorinsrc/middlewares/error-handler.ts, which answers with theApiErrorbody. - DB connections:
src/db-manager.tsowns connection creation and URL parsing; adapters import connection helpers throughsrc/adapters/connections.ts. - Open connections: the server can start without a database URL; until one opens, database routes answer 409 and the web app shows
/connect. Several connections stay open at once, one per browser tab: a tab keeps itsconnectionIdinsessionStorageand sends it asX-Connection-Idon every request, anddb-manager.tsanswers from that connection (a request without one gets the latest opened; one naming a closed connection gets 409). Adapters stay connection-agnostic, so keep any per-connection cache keyed by the client, not a module or adapter field.src/connection.tstests a URL before it opens it, reuses an already open URL, and saves it throughsrc/connections-store.tsto~/.dbstudio/connections.json(DB_STUDIO_CONFIG_DIRoverrides; owner-only file). Saved URLs never reach the browser:/connectionsreturns masked URLs and takes ids. The server listens on127.0.0.1and refuses requests whoseHostis not loopback unlessHOSTnames another address. - Adapters:
src/adapters/— Strategy + Template Method architecture. PostgreSQL, MySQL, SQL Server, MongoDB, SQLite, Redis, DuckDB, Oracle, and ClickHouse all route through registered adapters. - Adapter contract:
src/adapters/adapter.interface.tsdefinesIDbAdapter, the single interface routes depend on. - Adapter registry:
src/adapters/adapter.registry.tsexportsadapterRegistryandgetAdapter(dbType).src/adapters/register.tsregisters each adapter before routes mount. - Routes:
src/routes/— each route file is an Elysia plugin (new Elysia({ prefix })). Routes under/:dbType.use(adapterPlugin)fromsrc/routes/adapter.plugin.ts, which validates:dbTypeagainst the adapter registry and addsdbTypeandadapterto the context. - Errors: adapters throw
HTTPException(status, { message, cause })fromsrc/http-exception.ts. It has no framework dependency; never import Elysia into adapters.
Multi-database routing pattern: requests under /:dbType/* resolve their adapter through adapterPlugin, then handlers call methods on the adapter from the context. Routes never branch on database type; database-specific behavior lives inside the adapter classes.
- TanStack Router with file-based routing in
src/routes/ - TanStack Query for server state; Zustand for client state (
src/stores/) - shadcn/ui primitives live in
packages/ui; Monaco editor for JSON/query editing - API calls go through
src/shared/api/— pure transport wrappers over Axios - Cell rendering:
CellVariant="text" | "boolean" | "number" | "enum" | "json" | "date" | "array"
Feature-first structure — domain logic lives in src/features/, not flat src/hooks/ or src/components/:
src/
features/
query-runner/ — SQL editor, query execution, result display
schema/ — column list, add/edit column forms
table-builder/ — create table form, foreign key builder
tables/ — table grid, cell editing, pagination, exports
records/ — add record form, bulk insert sheets, FK reference picker
shared/
api/ — endpoint functions (pure transport wrappers)
query/ — query-key factories
stores/ — app-wide Zustand: database, overlay registry, preferences
hooks/ — cross-cutting hooks (databases list, rate limit, theme)
routes/ — file-based routes; each renders a feature screen only
components/ — shell components (sidebar, chat, command palette)Each feature folder follows this shape:
features/[name]/
components/ — feature-specific React components
hooks/ — query + mutation hooks scoped to this feature
stores/ — feature-local Zustand state (if any)
screens/ — entry-point component rendered by the matching route
index.ts — public barrel; only export what routes or other features needPackage dependency direction:
web → ui
web → shared
ui → shared (type imports only, if unavoidable)
server → shared
proxy → shared
Features within packages/web follow the same rule internally — a feature may import
from src/shared/, src/stores/, and sibling feature index.ts barrels, but never
from a sibling feature's internal files.
- Thin shell: Electron main (
src/main.ts, bundled todist-electron/bytsup, no preload) starts one server child at launch without a database URL (src/server.ts: free port,HOST=127.0.0.1) and opens a window onhttp://127.0.0.1:<port>/. Links off that origin open in the system browser. - Connections: the web app's
/connectpage handles them, saved by the server in~/.dbstudio, so the desktop app and the CLI share one list. - Server child: dev runs
packages/server/dist/index.jswith the systemnode; packaged runs Electron as Node (ELECTRON_RUN_AS_NODE=1) againstresources/server, staged byscripts/stage-server.mjswith the target platform's native packages. If it fails to start or exits, the app shows the last output lines and the log path, then quits. - Commands:
bun run dev:desktop(builds the server, then launches Electron),bun run --cwd packages/desktop package -- --mac --arch arm64(also--win,--linux). Version followspackages/server; builds are unsigned.
Three export paths:
@db-studio/shared/@db-studio/shared/types→src/types/index.tsshared/constants→src/constants/index.ts
DATABASE_TYPES = ["pg", "mysql", "mssql", "mongodb", "sqlite", "redis", "duckdb", "oracle", "clickhouse"]indatabase.types.tsDATABASE_ENGINESindatabase-engines.types.ts— per-engine traits (label, protocols, default port, data model, editor language, Live mode, schema selector, Indexes and Logs tabs); web reads it viauseDatabaseEngine()CellVariant/DataTypes— used for table cell rendering
- Linter/Formatter: Biome (tabs, 95-char width). Run
bun run formatto auto-fix. - Tests: Vitest (
server,web,www). Deliberately minimal — see Testing before adding any. Path aliases@→./srcand@db-studio/shared→../shared/srcare configured in eachvitest.config.ts. - Pre-commit hook: runs
bun run format && bun run typecheck && bun run test && bun run buildvia Husky. - CI: GitHub Actions on push to
stage— build → biome format → tests.
- Commit format:
<type>(<scope>): <message>(e.g.,feat(back): add mysql row insert) - Branch format:
<type>/<issue-number>/<description>(e.g.,feat/123/support-mysql) - Release scope: bump the app version and add a versioned changelog entry only when changes affect the desktop app or db-studio CLI/server, including their bundled web app, shared code, runtime dependencies, or packaging. Website-only changes in
www/, documentation, and unrelated files do not require an app release; keep the app version unchanged and omit a new versioned changelog entry. - App releases (
stage→main): before merging app changes, confirm that every user-facing app change since the last release has an entry inwww/src/lib/content/changelog.tsand thatversioninpackages/server/package.jsonmatches the new changelog version. Keep entries brief: one short sentence per change, written for users, with no implementation details. - Version bump: derive it from the new changelog entry, not from the size of the diff. Minor (
1.19.0→1.20.0) whenfeatureshas an entry or an improvement changes what users can see or do (a new screen, setting, CLI flag or engine). Patch (1.19.0→1.19.1) when the entry has onlybugsFixedor improvements users do not see (speed, dependencies, docs). Major when an existing setup stops working (a removed or renamed CLI flag, a connection URL that no longer parses, aconnections.jsonthat no longer loads, a dropped Node version); ask before releasing one. - Logging: server code never calls
console.*outsidesrc/cmd/(CLI output). UsewriteOperationalLog(level, event, fields)fromsrc/operational-log.ts:eventis a fixedsnake_casename, and anything variable goes infields— never interpolated into the name. Pass errors aserrorFields(error)(type + driver code); never log a message, connection string, table name or row. Each line already carriesinstance_idand, inside a request,request_id,operationanddb_type. The request ID arrives asX-Request-Id(the web client sends one), is echoed on the response and in theApiErrorbody, tagged on Sentry reports, and forwarded to the proxy, whose logs go throughwriteLoginpackages/proxy/src/log.ts. - PG specifics:
$1/$2placeholders, FK violation code23503; implemented inPgAdapter - MySQL specifics: backtick identifiers,
?placeholders, noRETURNINGclause, FK violation errno1451;mysql2'sexecute()requiresas anycast forunknown[]— this is expected, no suppression comment needed; implemented inMySqlAdapter - MSSQL specifics: bracket identifiers (
[col]), named@paramplaceholders viamssqlpackage, each value bound viarequest.input(name, value); implemented inMsSqlAdapter - MongoDB specifics: no schema enforcement;
ObjectIdhandling viaisValidObjectId/coerceObjectIdhelpers indb-manager.ts; "tables" are collections; implemented inMongoAdapter. - SQLite / libSQL specifics: one
SqliteAdapteron@libsql/client(intMode: "bigint", rows normalized intoRows) servessqlite://files (opened asfile:URLs) and remotelibsql:/// Turso URLs (?authToken=,?tls=0for local sqld) under dbTypesqlite;PRAGMA foreign_keysdoes not persist between requests on a remote server and is a no-op inside a transaction, so the table rebuild inalterColumngoes throughclient.migrate(). The adapter only sees theSqliteClientinterface insrc/sqlite-client.ts(fromLibsqlwraps the libSQL client). Cloudflare D1 is a third protocol of the same engine:d1://<account_id>/<database_id>?authToken=<api token>(orCLOUDFLARE_API_TOKEN) goes throughsrc/d1-client.ts, which calls the REST/rawendpoint, where one batch is one transaction and interactive transactions do not exist. SoupdateRecordsis one write batch with a guard after each UPDATE that fails the batch when the UPDATE matched no row. D1 always enforces foreign keys and cannot suspend them, soalterColumnreturns 400 for a table that other tables, or the table itself, reference. D1's internal_cf_*tables refuse every access and are hidden. D1 serializes integers as JSON doubles, so values past 2^53 arrive rounded; select them withCAST(x AS TEXT)to read them exactly. - Redis specifics: schemaless key-value store mapped onto six fixed type-tables (
strings,hashes,lists,sets,zsets,streams) — one row per key with type-specific value column; logical DBs0..N-1(fromCONFIG GET databases) appear as db-studio databases; pagination is forward-only viaSCAN(noprev, no sort, no filters — adapter throws 400); cluster mode is rejected at connect time;executeQueryaccepts redis-cli style command strings (quote-aware tokenizer) and shapes replies via a command-name dispatch table with single-cell JSON fallback; per-type row counts cached for 30s; implemented inRedisAdapterusingioredis. Schema mutations (createTable/deleteTable/addColumn/etc.) all return 400. - DuckDB specifics: file-based like SQLite (
duckdb:///abs/path.duckdborduckdb://./rel.duckdb); oneDuckDBInstance+ connection per file via@duckdb/node-api, and every call goes throughwithDuckdbConnection(), which serializes access so transactions cannot interleave; the instance checkpoints on every commit (checkpoint_threshold: "0b") because DuckDB cannot replay anALTER TABLEon a table with anextval()default from the WAL; a file locked by another process surfaces as 503;"ident"quoting,?placeholders; rows go through one boundary (getRowObjectsJson(), then safe-range BIGINT/HUGEINT to numbers); keyset pagination sorts NULLS LAST in both directions and builds a per-column predicate(c1 after v1) OR (c1 = v1 AND c2 after v2) …honouring each column's direction and NULLs (buildKeysetPredicate), never a row-value tuple comparison; only the current schema (main) is listed; identity/serial columns become a<table>_<column>_seqsequence default; FK actions CASCADE/SET NULL/SET DEFAULT, ADD COLUMN with UNIQUE/PRIMARY KEY, and ALTER on tables other tables reference return 400;forceDeleteRecordsuses the ordinary single-statement delete and refuses 400 when dependent rows exist, because DuckDB cannot delete a parent and its children in one atomic transaction; implemented inDuckDbAdapter. - Oracle specifics:
oracle://user:pass@host:1521/SERVICEviaoracledbin Thin mode (no Instant Client), one pool per process; the connected container (CON_NAME) is the only database and only the user's own tables (USER_TABLES) are listed; every identifier is quoted as stored, so"ident"quoting preserves case, and names containing"are rejected with 400 because Oracle cannot quote them;:nbind placeholders; OFFSET/FETCH pagination with an offset cursor like MSSQL, ordered by the user sort then the primary key (ROWIDwithout one);textbecomesVARCHAR2(4000), not CLOB, so it stays sortable and comparable; DDL auto-commits, so every schema operation is a single statement that cannot half-apply;oracleServerVersion >= 23aigates nativeBOOLEANandJSON(older releases getNUMBER(1)andCLOB); array columns, ON UPDATE actions and ON DELETE SET DEFAULT return 400; implemented inOracleAdapter. Adapter-owned reads returnDATEandTIMESTAMPcolumns as strings and preserve nine fractional digits. The query runner returns 400 for native temporal result columns because Thin mode converts them to millisecond-only JavaScriptDatevalues. Select those columns withTO_CHARand an explicit Oracle format. - ClickHouse specifics: OLAP engine over the HTTP interface via
@clickhouse/client(clickhouse://→ http,clickhouses://→ https, one client per database indb-manager.ts); every request setswait_end_of_query = 1so late server failures reach the API; row counts come fromsystem.tables.total_rows(nocount()scans);isPrimaryKeymarks sorting-key columns, which are not unique, so pagination is OFFSET-based ordered by user sort, then the sorting key, then_part/_part_offset; inserts useJSONEachRow; grid updates, deletes, and force deletes return 400 because sorting keys cannot identify one row safely, while explicit query-runner mutations remain available and wait withmutations_sync = 2; created tables areMergeTree ORDER BY (<pk>), identity/serial columns becomeUInt64 DEFAULT generateSnowflakeID(); no FKs or UNIQUE (400); JSON output quotes 64/128/256-bit integers and decimals so their exact values cross the JavaScript boundary as strings; implemented inClickhouseAdapter.
| Artifact | Convention | Example |
|---|---|---|
| React hook | use-[feature].ts |
use-databases-list.ts |
| Component | [feature]-[description].tsx |
sidebar-list-tables-item.tsx |
| Store | [entity].store.ts |
database.store.ts, queries.store.ts |
| Server route | [resource].routes.ts |
tables.routes.ts, records.routes.ts |
| DB adapter | [db].adapter.ts |
pg.adapter.ts, mysql.adapter.ts |
| SQL query builder | [db].query-builder.ts |
pg.query-builder.ts, mssql.query-builder.ts |
| MongoDB pipeline builder | mongo.pipeline-builder.ts |
mongo.pipeline-builder.ts |
| Shared type | [feature].types.ts |
column-info.types.ts |
| Core type | [feature].type.ts |
table.type.ts |
| Test | [file-name].test.ts |
parse-bulk-data.test.ts |
Naming:
- Schema validators (Zod):
[entity]Schema→databaseSchema - Inferred types:
[Entity]SchemaType→DatabaseSchemaType,ColumnInfoSchemaType - Constants array:
ALL_CAPS→DATABASE_TYPES
Pattern (Zod-first):
export const databaseSchema = z.object({
db: z.string(),
});
export type DatabaseSchemaType = z.infer<typeof databaseSchema>;
export const DATABASE_TYPES = ["pg", "mysql", "mssql", "mongodb", "sqlite", "redis", "duckdb", "oracle", "clickhouse"] as const;
export const databaseTypeSchema = z.enum(DATABASE_TYPES);
export type DatabaseTypeSchema = z.infer<typeof databaseTypeSchema>;Exports: All shared types re-exported from packages/shared/src/types/index.ts using .js extensions (ESM):
export * from "./database.types.js";
export * from "./column-info.types.js";TanStack Table module augmentation (in packages/web):
declare module "@tanstack/react-table" {
interface ColumnMeta<_TData extends RowData, _TValue> {
variant?: CellVariant;
isPrimaryKey?: boolean;
// ...
}
}Feature hooks (query, mutation, derived state scoped to one feature):
Location: packages/web/src/features/[feature]/hooks/use-[name].ts
Cross-cutting hooks (app-wide concerns: databases list, rate limit, theme):
Location: packages/web/src/hooks/use-[name].ts
Pattern — return a named object (not a tuple):
export const useDatabasesList = () => {
const { setDbType } = useDatabaseStore();
const { data, isLoading } = useQuery({
queryKey: [CONSTANTS.CACHE_KEYS.DATABASES_LIST],
queryFn: () => rootApi.get<BaseResponse<DatabaseListSchemaType>>("/databases"),
select: (res) => res.data.data,
staleTime: 1000 * 60 * 5,
});
return { databases: data?.databases, isLoading };
};Categories:
- Data fetching:
useQueryfrom TanStack Query, return{ data, isLoading, error, refetch } - Initialization: use
useRefguard to prevent double-init - State helpers: wrap store + derived logic, expose clean API
Export: named export, PascalCase, same name as file (kebab → Pascal):
// File: sidebar-list-tables-item.tsx
export const SidebarListTablesItem = ({ tableName, rowCount }: TableInfoSchemaType) => { ... };Props: destructure inline, type with imported schema types (no separate Props interface):
export const MyComponent = ({ id, label }: SomeSchemaType) => { ... };Imports order (enforced by Biome):
- External packages
@db-studio/shared/types- Local
@/aliases
className composition: always via cn() utility.
Location: packages/web/src/stores/[entity].store.ts
Pattern:
interface DatabaseStore {
selectedDatabase: string | null;
setSelectedDatabase: (db: string | null) => void;
}
export const useDatabaseStore = create<DatabaseStore>()((set) => ({
selectedDatabase: null,
setSelectedDatabase: (db) => set({ selectedDatabase: db }),
}));With persistence:
export const useQueriesStore = create<QueriesStore>()(
persist(
(set, get) => ({ ... }),
{ name: "dbstudio-queries" }, // localStorage key
),
);Rules:
- Updates always produce new objects (immutable pattern via
set) - Use
get()for reading state inside derived/computed functions - No selectors — consumers destructure from the hook directly
All sheets, drawers, and modal overlays are controlled through a single typed registry.
Never create a new ad-hoc isOpen boolean in a store or component for a named overlay.
type OverlayId =
| "table-builder.create-table"
| `table-builder.add-foreign-key-${number}`
| "schema.add-column"
| "schema.edit-column"
| "records.add-record"
| "records.bulk-insert"
| "records.bulk-insert-csv"
| "records.bulk-insert-excel"
| "records.bulk-insert-json"
| "records.record-reference"
| "chat.assistant";Usage:
const { openOverlay, closeOverlay, isOverlayOpen } = useOverlayStore();
// open
openOverlay("records.add-record");
// close
closeOverlay("records.add-record");
// check
isOverlayOpen("records.add-record");To add a new overlay: add its ID to the OverlayId union in overlay.store.ts, then
render the overlay component wherever it belongs (route screen or app shell).
Location: packages/server/src/routes/[resource].routes.ts
Pattern:
export const tablesRoutes = new Elysia({ prefix: "/tables" })
.use(adapterPlugin)
.get("/", async ({ adapter, query }) => ({ data: await adapter.getTablesList(query.db) }), {
query: databaseSchema,
})
.post(
"/",
async ({ adapter, query, body }) => {
await adapter.createTable({ tableData: body, db: query.db });
return { data: `Table ${body.tableName} created successfully` };
},
{ query: databaseSchema, body: createTableSchema },
);Rules:
- Chain every call on the instance; Elysia infers context and response types through the chain.
- Validation via the route's
query/params/bodyoptions with the Zod schemas from@db-studio/shared/types. A failure reacheshandleErrorand answers 400{ error: "Validation error", details }. - Query values reach the schema as raw strings:
create-server.tsreplaces Elysia's query parser, which JSON-parses[...]/{...}values and splits on commas. - DB dispatch via the
adapterthatadapterPluginadds — never switch/if-else on dbType in routes. - Return the
{ data }envelope; usestatus(409, { data })for a non-200 success body and throwHTTPExceptionfor failures. - Return a stream as its body and put its headers on
set.headers: Elysia on Node drops the headers of a returned chunkedResponse. - Do not use Elysia's
file()on Node: it sendsContent-Lengthtogether with chunked encoding.
Architecture: Strategy + Template Method. Each database is a single class (PgAdapter, MySqlAdapter, MsSqlAdapter, MongoAdapter) registered at boot. SQL adapters extend BaseAdapter; MongoDB overrides the template methods that require document-store behavior.
Key files:
src/adapters/adapter.interface.ts—IDbAdapterinterface and adapter method contractsrc/adapters/base.adapter.ts—BaseAdapter: implementsgetTableData()andexportTableData()as template methods; provideswrapError(),encodeCursor(),decodeCursor()src/adapters/adapter.registry.ts—AdapterRegistrysingleton plusgetAdapter();get()throwsHTTPException(400)for unregistered typessrc/adapters/register.ts—registerAdapters()called at boot before routes mountsrc/adapters/[db]/[db].adapter.ts— concrete adapter per databasesrc/adapters/[db]/[db].query-builder.ts— SQL construction helpers (WHERE, ORDER BY, cursor clauses)src/adapters/mongo/mongo.pipeline-builder.ts— MongoDB$match,$sort, skip/limit, and cursor helperssrc/adapters/connections.ts— adapter-facing barrel for connection helpers fromdb-manager.ts
Request flow:
Request → adapterPlugin validates dbType → getAdapter(dbType)
→ adapterRegistry.get(dbType) returns the adapter
→ route calls adapter.someMethod(params)
Implementing a method (all adapters follow this structure):
export class PgAdapter extends BaseAdapter {
// Abstract method implementations required by BaseAdapter
protected async runQuery<T>(db: string, sql: string, values: unknown[]): Promise<T> {
const pool = getDbPool(db);
const result = await pool.query(sql, values);
return result.rows as T;
}
protected quoteIdentifier(name: string): string { return `"${name}"`; }
mapToUniversalType(nativeType: string): DataTypes { ... }
mapFromUniversalType(universalType: string): string { ... }
// IDbAdapter method — validate, build SQL, execute
async addColumn(params: AddColumnParamsSchemaType): Promise<void> {
const pool = getDbPool(params.db);
// 1. Assert table/column existence → throw HTTPException on failure
// 2. Build SQL using query-builder helpers
// 3. Execute
}
}Template method getTableData(): implemented once in BaseAdapter; calls buildTableDataQuery() → runQuery() → normalizeRows() → buildCursors(). Adapters only override if their DB requires a fundamentally different approach (e.g. MsSqlAdapter overrides because mssql requests need per-value .input() calls).
Overriding template methods: any override that bypasses BaseAdapter.getTableData() MUST wrap its body in try/catch and call throw this.wrapError(e) — otherwise connection errors won't be surfaced as 503.
Error handling: BaseAdapter.wrapError(e) maps connection errors (ECONNREFUSED, ETIMEDOUT, Login failed, etc.) to HTTPException(503) and all others to HTTPException(500). It is called automatically by the template methods; manual calls are only needed in overrides.
- Create
src/adapters/<dbname>/<dbname>.adapter.tsimplementingIDbAdapter. - Create
src/adapters/<dbname>/<dbname>.query-builder.tsfor SQL databases, or a domain-specific builder likemongo.pipeline-builder.tsfor non-SQL databases. - Add connection handling in
src/db-manager.tsand expose adapter-facing helpers throughsrc/adapters/connections.ts. - Register the adapter in
src/adapters/register.ts:adapterRegistry.register("<dbname>", new MyAdapter()). - Add
"<dbname>"toDATABASE_TYPESinpackages/shared/src/types/database.types.ts. - Add a
DATABASE_ENGINESentry inpackages/shared/src/types/database-engines.types.ts(typecheck fails until you do). It supplies the label, URL protocols, default port, data model, editor language, Live mode support, schema selector, read-only sessions (readOnly), Indexes tab support (indexes) and Logs tab support (serverLogs); an engine withserverLogs: trueimplementsgetServerLogs, which answers{ available: false, reason }when the server keeps its log out of reach. The server and web read these fields instead of comparingdbTypeto literals, so do not add newdbType === "<dbname>"checks.
Configured in each package's tsconfig.json:
// packages/web → @/ maps to ./src/
import { useDatabaseStore } from "@/stores/database.store";
// cross-package → shared/ maps to ../shared/src/
import type { TableInfoSchemaType } from "@db-studio/shared/types";
// server internal → @/ maps to ./src/
import { getAdapter } from "@/adapters/adapter.registry.js"; // .js extension required (ESM)
import { PgAdapter } from "@/adapters/pg/pg.adapter.js"; // adapters follow the same ruleWrap mutation calls with toast.promise for consistent loading/success/error feedback.
Pattern:
const addColumn = async (
data: AddColumnSchemaType,
options?: {
onSuccess?: () => void;
onError?: (error: MutationError) => void;
},
) =>
toast.promise(addColumnMutation(data, options), {
loading: "Adding column...",
success: (message) => message || "Column added successfully",
error: (error: MutationError) =>
(typeof error.details === "string" && error.details) ||
error.message ||
"Failed to add column",
});Rules:
- Always use
toast.promise— never calltoast.success/toast.errormanually around mutations successcallback receives the mutation return value; prefer server message with a fallback stringerrorcallback: checkerror.details(string) first, thenerror.message, then a static fallback
Canonical API layer — pure transport wrappers, no React or Zustand:
src/shared/api/
client.ts — two Axios instances (rootApi + api) and setDbType()
databases.ts — database list endpoints
tables.ts — table data, schema, export endpoints
records.ts — record CRUD endpoints
query.ts — SQL query execution endpoint
chat.ts — AI assistant endpoint
index.ts — barrel re-exportTwo Axios instances in client.ts:
rootApi— unscoped base URL, used for/databasesetc.api— scoped to/{dbType}, base URL updated viasetDbType()
export const setDbType = (type: DatabaseTypeSchema): void => {
api.defaults.baseURL = `${getBaseUrl()}/${type}`;
};src/lib/api.ts is a legacy compatibility facade — do not add new code there.
This repo deliberately carries very few unit tests. A large mock-heavy unit suite was deleted because it could not fail for any bug a user would ever hit. Do not grow it back.
Writing tests after the implementation only produces tests shaped like the implementation. They restate the code you just wrote, pass on the first run, and keep passing after you break the behaviour. If you finished a change and your instinct is "now add a test for it" — don't. Verify the change with an E2E run instead.
The only tests written after code are regression tests for a specific reported bug, and only when the test fails before the fix and passes after it. If you cannot demonstrate that red-then-green transition, you do not have a regression test, you have decoration.
E2E is the default and usually the only testing mechanism here. Use E2E to verify that a complex
feature actually works: drive the real app against a real database (see scripts/init-db-*.sh
for seeded instances of each supported engine), perform the user's actual sequence of actions,
and assert on what the user would see.
Every E2E test must end by producing a verifiable, repeatable artifact. A test that only prints "pass" is not evidence. The artifact is the deliverable — a screenshot, a recorded trace, an exported result set, a dumped table state, a diffable JSON snapshot of the final DB rows. It must be:
- Verifiable — a human can open it and independently confirm the feature worked.
- Repeatable — running the test again from the same seeded state produces the same artifact. No timestamps, no random IDs, no connection strings, no machine-specific paths baked into it. Normalise or redact anything non-deterministic before writing the artifact out.
Write artifacts to a gitignored output directory and name them after the scenario, so a reviewer can pair a failing run with the exact artifact it produced.
Isolated tests are a last resort, reserved for logic that E2E genuinely cannot reach or cannot pin down precisely — parsers, SQL and pipeline builders, value formatters, diffing, redaction, auth token validation.
When you decide an isolated test is warranted, the order is mandatory:
- First, write down every way the system could fail. In the PR description or a comment at
the top of the test file, enumerate the real failure modes: wrong identifier quoting for a
dialect, wrong placeholder style, off-by-one in LIMIT/OFFSET, a composite key silently
matching the wrong row,
nullvs""vs0vsfalsecollapsing into each other, an unescaped quote enabling injection, a secret surviving redaction. - Then write the tests, one per failure mode you listed, each with an expected value you derived independently of the implementation.
- Then write or fix the code.
If you cannot name a concrete failure mode, there is no test to write.
Apply one question to every test: if I introduced a plausible bug in the code under test, would this test fail? If the answer is no, delete it. In particular, never write:
- Mock theater — mocking the unit's only collaborator, then asserting the mock's configured
return value came back out. This proves
vi.fn()works. - Call assertions with no argument checks —
expect(mockFn).toHaveBeenCalled(). - Pass-through route tests — asserting a status code for a handler whose whole body is
return c.json(await adapter.x()), whereadapteris mocked. - Store setter/getter tests —
setFoo(true)thenexpect(foo).toBe(true); "starts with default values"; "reset() restores defaults". - Render-the-prop tests — rendering a component and asserting a string that was passed in as
a prop appears on screen, or that clicking calls a
vi.fn()prop. - Tests of what TypeScript already guarantees — required props, enum membership, field types.
- Copy-paste families — the same test duplicated per database type against one shared mock.
- Snapshot tests.
- Vitest.
bun run testat the root runs every package's suite. - Server:
cd packages/server && bunx vitest run tests/path/to/file.test.ts. - Web:
cd packages/web && bunx vitest run src/path/to/file.test.ts(happy-dom). - Path aliases
@→./srcand@db-studio/shared→../shared/srcare set in eachvitest.config.ts. - Naming:
[file-name].test.ts, colocated with the source inpackages/webandwww; underpackages/server/tests/[area]/for the server.
Benchmark a performance change with packages/server/e2e/perf.ts before claiming it; its
header explains the seed database and build snapshots. Compare builds in one interleaved run,
never two separate runs: wall time swings 20–40% between identical builds on a busy machine.
Judge by the layout and style-recalculation counts, best-of-N and the paired change, and run
an identical build against itself first if you need the noise floor. Changes that save database
round trips only show with PERF_DB_LATENCY_MS set.