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 rowImpact
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-280 — sqlFunction.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:setuphad populated the row correctly; the framework's auth read path just couldn't see it.