ReviewOS

stacks/bun-query-builder

ORM model API ignores configured dialect — hardcoded to in-memory bun:sqlite

#1021
Closed glennmichael123 opened this 22 days ago · 0 comments
22 days ago

Summary

ModelQueryBuilderImpl and the related model-instance methods in packages/bun-query-builder/src/orm.ts call getDatabase(), which unconditionally constructs an in-memory bun:sqlite Database, regardless of the dialect/connection configured via setConfig(). The result is that any project on MySQL or Postgres has its Model.where().first() / Model.create() calls silently route to a fresh, empty SQLite database — every query returns "no such table".

The direct query path (db.selectFrom(...)getOrCreateBunSql()) honours the configured dialect correctly. The bug is limited to the model API.

Reproduction

DB_CONNECTION=mysql DB_DATABASE=mydb bun -e "
import { setConfig, createModel, createQueryBuilder } from 'bun-query-builder'

setConfig({
  dialect: 'mysql',
  database: { database: 'mydb', host: '127.0.0.1', username: 'root', password: '', port: 3306 },
})

const qb = createQueryBuilder()
console.log('direct:', await qb.selectFrom('users').selectAll().limit(1).execute())  //  hits MySQL

const User = createModel({ name: 'User', table: 'users', primaryKey: 'id', autoIncrement: true, attributes: {} })
console.log('model:',  await User.where('id', 1).first())                            //  SQLiteError: no such table: users
"

Output:

direct: []
model:  SQLiteError: no such table: users
    at ... pantry/bun-query-builder/dist/src/index.js:25121:26  (ModelQueryBuilderImpl.get)

Root cause

packages/bun-query-builder/src/orm.ts:312–316:

export function getDatabase(): Database {
  if (!globalDb) {
    globalDb = new Database(':memory:', { create: true })  // bun:sqlite, always
  }
  return globalDb
}

Every model method below routes through this — confirmed via grep -n 'getDatabase()' packages/bun-query-builder/src/orm.ts (30+ call sites). Examples:

  • ModelQueryBuilderImpl.get() (line ~1149) — db.query(sql).all(...params)
  • ModelQueryBuilderImpl.first() — calls .get()
  • ModelQueryBuilderImpl.count() (line ~1161)
  • findOrFail / instance .save() / .delete() / belongsToMany pivot reads, etc.

configureOrm({ database }) lets a caller pass a bun:sqlite Database instance OR a sqlite filename, but no path through it accepts a non-sqlite connection — the API surface itself assumes sqlite.

Why the direct path works but the model path doesn't

Two separate code paths in the same package:

APIConnection acquired viaHonours setConfig({ dialect: 'mysql', … })?
createQueryBuilder().selectFrom(...)getOrCreateBunSql()getBunSql()new SQL(connectionString)
createModel(...).where(...).first()getDatabase()new Database(':memory:')

This is what makes the bug so surprising in downstream projects: the same setConfig call configures one half of the surface and silently no-ops on the other.

Impact

Any framework that builds its model layer on top of createModel (notably stacksjs/stacks's @stacksjs/orm) is fundamentally MySQL/Postgres-incompatible at runtime today, regardless of how carefully the consuming project sets DB_CONNECTION. Migrations land on the real database; queries land on the in-memory sqlite; both succeed silently from their respective sides.

Proposed fix (shape, not a PR yet)

  1. Make globalDb a discriminated union — { kind: 'sqlite', conn: Database } | { kind: 'driver', sql: BunSQL }.
  2. getDatabase() consults the configured dialect (via the existing getConfig() / config5). If dialect is mysql / postgres, return the driver variant routed through getOrCreateBunSql() (which already supports all dialects via Bun's SQL). Otherwise keep the current sqlite path.
  3. Introduce small execAll(sql, params) / execGet(sql, params) / execRun(sql, params) helpers that branch on the union. Replace every db.query(sql).all(...params) / .get(...) / .run(...) site (~30) with the helper.
  4. Methods that are currently sync (get, first, count, …) become async — Bun's SQL driver is async. This is a breaking change for callers using these without await, but those callers are already buggy (they receive an unresolved Promise wrapped in a ModelInstance proxy).
  5. SQL emitted by buildSelectQuery / buildInsertQuery needs dialect-aware placeholders (? for sqlite/mysql, $1, $2, … for postgres). The dialect is already in scope via config5.dialect.
  6. Schema-introspection queries (SELECT * FROM sqlite_master …) need MySQL (information_schema.tables) + Postgres (information_schema.tables/pg_catalog) variants. Estimated 3–5 sites.

Net change: ~300–500 LOC in orm.ts + corresponding test additions. The existing ORM test suite is sqlite-only and would need MySQL + Postgres parameterisation.

Workarounds

For affected downstream projects, until this is fixed:

  • Bypass the model API where MySQL/Postgres is needed: use db.selectFrom('users').where(...).execute() directly. Loses model traits (uuid/timestamps/hooks) but the connection routes correctly.
  • Stay on SQLite for the model surface.

Happy to send a PR implementing the proposed fix if the approach is acceptable — flagging it as an issue first because the async-cascade in step 4 is a breaking-change call that should be the maintainer's, not mine.

Sign in to comment on this issue.