ReviewOS

stacks/bun-query-builder

db.unsafe() under SQLite returns a non-thenable builder; await yields builder object instead of rows

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

Summary

await db.unsafe('SELECT ...') does not return rows when running against SQLite. It returns the query builder object, and the caller has to remember to chain .execute(). Under Postgres (Bun's native sql.unsafe), await auto-resolves to rows. The two driver paths therefore diverge silently.

The JSDoc + type signature for db.unsafe both promise auto-resolve:

// client.ts:1681
* const rows = await db.unsafe('SELECT 1 as one')
// type
unsafe: (query: string, params?: any[]) => Promise<any>

…but the SQLite path (src/db.ts createSQLiteSQL → sqlFunction.unsafe) returns a plain object with { sql, values, execute, raw, toString, cancel } and no .then. Awaiting it just resolves to the object itself.

Reproducer

import { db } from '@stacksjs/database' // proxies bun-query-builder, SQLite driver

const a = await db.unsafe('SELECT id FROM users LIMIT 1')
console.log('a[0]:', a[0]) // undefined — `a` is the builder, not rows

const b = await db.unsafe('SELECT id FROM users LIMIT 1').execute()
console.log('b[0]:', b[0]) // { id: 1 } — actual row

Impact

This isn't theoretical — it silently breaks the framework's own auth flow on SQLite. stacks/framework/core/auth/src/tokens.ts has ~20 await db.unsafe(\SELECT ...`)call sites (no.execute()), all of which return the builder object. The user-visible symptom is "No personal access client found. Run ./buddy auth:setup first."on every login attempt, despite the row being correctly inserted byauth:setup`. (Auth, tokens, refresh — everything tokens.ts touches — is broken on SQLite.)

A consumer following the docs faithfully gets a working Postgres app and a broken SQLite app.

Root cause

packages/bun-query-builder/src/db.ts:255-280sqlFunction.unsafe:

sqlFunction.unsafe = (sql: string, params: any[] = []) => {
  return {
    sql,
    values: params,
    execute: () => { /* runs the query, returns Promise<rows> */ },
    raw: () => sql,
    toString: () => sql,
    cancel: () => {},
  }
}

No .then. So await resolves to the object itself rather than to the rows.

Suggested fix

Add .then that delegates to .execute().then(...). This makes the SQLite path Promise/A+-conformant and matches the documented contract + Postgres path behaviour:

sqlFunction.unsafe = (sql: string, params: any[] = []) => {
  const execute = (): Promise<any> => {
    try {
      const trimmed = sql.trim().toUpperCase()
      if (trimmed.startsWith('SELECT') || trimmed.startsWith('PRAGMA'))
        return Promise.resolve(wrapper.query(sql, params))
      return Promise.resolve(wrapper.run(sql, params))
    }
    catch (error) {
      return Promise.reject(error)
    }
  }
  return {
    sql,
    values: params,
    execute,
    then: (onFulfilled, onRejected) => execute().then(onFulfilled, onRejected),
    raw: () => sql,
    toString: () => sql,
    cancel: () => {},
  }
}

await db.unsafe(...) now resolves to rows in both drivers, matching the type/doc and (more importantly) what every caller assumes.

If the lazy-execute shape is intentional and there's a reason to keep await returning the builder, the documented signature + JSDoc example need to flip to await db.unsafe(...).execute() everywhere, and @stacksjs/auth's tokens.ts needs ~20 .execute() additions.

Workaround

await db.unsafe(...).execute() — explicit. Works under both drivers (Postgres also has .execute() available on the SQLQuery).

Environment

  • bun-query-builder vendored via stacks pantry (symlinked to a checkout)
  • Bun 1.3.13, SQLite driver
  • Caught while debugging "No personal access client found" on a Stacks app — auth:setup had populated the row correctly; the framework's auth read path just couldn't see it.

Sign in to comment on this issue.