also looking at this
fix(client): a builder's writes belong to the builder's own connection
#1065
2 files
+161
-29
| @@ -2740,6 +2740,28 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti | ||
| 2740 | 2740 | // Single boundary cast: `state.sql` is `any` (allows mock/tx injection) and |
| 2741 | 2741 | // getOrCreateBunSql() returns Bun's `SQL`; both satisfy DriverConnection. With |
| 2742 | 2742 | // `_sql` typed, the downstream `.unsafe(...)` calls no longer need casts (#1044). |
| 2743 | // | |
| 2744 | // EVERY statement this builder executes must go through `_sql`, never the | |
| 2745 | // module-global `bunSql`. They are the same object only for a plain builder; | |
| 2746 | // the moment one is created with its own connection — `db.transaction()`, | |
| 2747 | // `savepoint()`, `beginDistributed()`, `reserve()`, or an explicit | |
| 2748 | // `createQueryBuilder({ sql })` — they are two different connections, and the | |
| 2749 | // global is a DIFFERENT pooled session from the one the builder's own reads | |
| 2750 | // and writes use. | |
| 2751 | // | |
| 2752 | // That is not a cosmetic distinction. A write issued on the global while the | |
| 2753 | // caller is inside `db.transaction()` autocommits on its own session, so it | |
| 2754 | // outlives a ROLLBACK: the transaction fails, the caller sees the error, and | |
| 2755 | // the row is still there. A read issued on the global cannot see the | |
| 2756 | // transaction's uncommitted rows, so read-then-decide logic acts on stale | |
| 2757 | // data. With `pool: { max: 1 }` it does not even get that far — the call | |
| 2758 | // blocks forever waiting for the single connection its own transaction is | |
| 2759 | // holding. All three are silent; none produces a driver error. | |
| 2760 | // | |
| 2761 | // The exceptions are the pool-lifecycle methods (`reserve`, `close`), which | |
| 2762 | // act on the pool itself rather than on a session, and the two-phase | |
| 2763 | // `commitDistributed`/`rollbackDistributed`, which by design run outside the | |
| 2764 | // transaction that prepared them. | |
| 2743 | 2765 | const _sql: DriverConnection = (state?.sql ?? getOrCreateBunSql()) as unknown as DriverConnection |
| 2744 | 2766 | const meta = state?.meta |
| 2745 | 2767 | const schema = state?.schema |
| @@ -6973,7 +6995,7 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti | ||
| 6973 | 6995 | let hash = 7 |
| 6974 | 6996 | for (let i = 0; i < s.length; i++) hash = (hash * 31 + s.charCodeAt(i)) | 0 |
| 6975 | 6997 | const k = typeof key === 'number' ? key : Math.abs(hash) |
| 6976 | const q = bunSql`SELECT pg_advisory_lock(${k})` | |
| 6998 | const q = _sql`SELECT pg_advisory_lock(${k})` | |
| 6977 | 6999 | await runWithHooks<any[]>(q, 'raw') |
| 6978 | 7000 | return |
| 6979 | 7001 | } |
| @@ -6981,7 +7003,7 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti | ||
| 6981 | 7003 | // MySQL has `GET_LOCK(name, timeout)`. Wait indefinitely |
| 6982 | 7004 | // (timeout=-1) to match Postgres `pg_advisory_lock` semantics. |
| 6983 | 7005 | const lockName = `bqb:${String(key)}` |
| 6984 | const q = bunSql`SELECT GET_LOCK(${lockName}, -1) AS ok` | |
| 7006 | const q = _sql`SELECT GET_LOCK(${lockName}, -1) AS ok` | |
| 6985 | 7007 | await runWithHooks<any[]>(q, 'raw') |
| 6986 | 7008 | return |
| 6987 | 7009 | } |
| @@ -6997,7 +7019,7 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti | ||
| 6997 | 7019 | let hash = 7 |
| 6998 | 7020 | for (let i = 0; i < s.length; i++) hash = (hash * 31 + s.charCodeAt(i)) | 0 |
| 6999 | 7021 | const k = typeof key === 'number' ? key : Math.abs(hash) |
| 7000 | const q = bunSql`SELECT pg_try_advisory_lock(${k}) as ok` | |
| 7022 | const q = _sql`SELECT pg_try_advisory_lock(${k}) as ok` | |
| 7001 | 7023 | const rows = await runWithHooks<any[]>(q, 'raw') |
| 7002 | 7024 | return Boolean(rows?.[0]?.ok) |
| 7003 | 7025 | } |
| @@ -7005,7 +7027,7 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti | ||
| 7005 | 7027 | // MySQL `GET_LOCK(name, 0)` returns 1 immediately if free, 0 |
| 7006 | 7028 | // if held by another connection. |
| 7007 | 7029 | const lockName = `bqb:${String(key)}` |
| 7008 | const q = bunSql`SELECT GET_LOCK(${lockName}, 0) AS ok` | |
| 7030 | const q = _sql`SELECT GET_LOCK(${lockName}, 0) AS ok` | |
| 7009 | 7031 | const rows = await runWithHooks<any[]>(q, 'raw') |
| 7010 | 7032 | return Number(rows?.[0]?.ok) === 1 |
| 7011 | 7033 | } |
| @@ -7023,7 +7045,7 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti | ||
| 7023 | 7045 | return (_sql as any).unsafe(query, params) |
| 7024 | 7046 | }, |
| 7025 | 7047 | file(path: string, params?: any[]) { |
| 7026 | return (bunSql as any).file(path, params) | |
| 7048 | return (_sql as any).file(path, params) | |
| 7027 | 7049 | }, |
| 7028 | 7050 | async reserve() { |
| 7029 | 7051 | const reserved = await (bunSql as any).reserve() |
| @@ -7058,7 +7080,7 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti | ||
| 7058 | 7080 | }, |
| 7059 | 7081 | async ping() { |
| 7060 | 7082 | try { |
| 7061 | const q = bunSql`SELECT 1` | |
| 7083 | const q = _sql`SELECT 1` | |
| 7062 | 7084 | await runWithHooks<any[]>(q, 'select') |
| 7063 | 7085 | return true |
| 7064 | 7086 | } |
renamed throughout · 34 hidden lines
| @@ -7242,28 +7264,28 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti renamed throughout | ||
| 7242 | 7264 | const sqlText = isMysqlLike(config.dialect) |
| 7243 | 7265 | ? `INSERT IGNORE INTO ${tbl} (${colsSql}) VALUES ${valuesSql}` |
| 7244 | 7266 | : `INSERT INTO ${tbl} (${colsSql}) VALUES ${valuesSql} ON CONFLICT DO NOTHING` |
| 7245 | return (bunSql.unsafe(sqlText, params) as any).execute() | |
| 7267 | return (_sql.unsafe(sqlText, params) as any).execute() | |
| 7246 | 7268 | }, |
| 7247 | 7269 | async insertGetId(table, values, idColumn = 'id' as any) { |
| 7248 | 7270 | const { colsSql, valuesSql, params } = buildInsertClause([values as Record<string, any>]) |
| 7249 | 7271 | const tbl = quoteInsertIdent(String(table)) |
| 7250 | 7272 | if (isMysqlLike(config.dialect)) { |
| 7251 | 7273 | // MySQL has no RETURNING — insert then read LAST_INSERT_ID(). |
| 7252 | await (bunSql.unsafe(`INSERT INTO ${tbl} (${colsSql}) VALUES ${valuesSql}`, params) as any).execute() | |
| 7253 | const [row] = await (bunSql.unsafe(`SELECT LAST_INSERT_ID() as id`) as any).execute() | |
| 7274 | await (_sql.unsafe(`INSERT INTO ${tbl} (${colsSql}) VALUES ${valuesSql}`, params) as any).execute() | |
| 7275 | const [row] = await (_sql.unsafe(`SELECT LAST_INSERT_ID() as id`) as any).execute() | |
| 7254 | 7276 | return row?.id |
| 7255 | 7277 | } |
| 7256 | 7278 | if (config.dialect === 'sqlite') { |
| 7257 | 7279 | // The bun:sqlite wrapper returns { changes, lastInsertRowid } rather |
| 7258 | 7280 | // than RETURNING rows, so read the rowid directly. |
| 7259 | const res = await (bunSql.unsafe(`INSERT INTO ${tbl} (${colsSql}) VALUES ${valuesSql}`, params) as any).execute() | |
| 7281 | const res = await (_sql.unsafe(`INSERT INTO ${tbl} (${colsSql}) VALUES ${valuesSql}`, params) as any).execute() | |
| 7260 | 7282 | if (res?.lastInsertRowid != null) |
| 7261 | 7283 | return res.lastInsertRowid |
| 7262 | const [row] = await (bunSql.unsafe(`SELECT last_insert_rowid() as id`) as any).execute() | |
| 7284 | const [row] = await (_sql.unsafe(`SELECT last_insert_rowid() as id`) as any).execute() | |
| 7263 | 7285 | return row?.id |
| 7264 | 7286 | } |
| 7265 | 7287 | // Postgres supports RETURNING. |
| 7266 | const [row] = await (bunSql.unsafe(`INSERT INTO ${tbl} (${colsSql}) VALUES ${valuesSql} RETURNING ${quoteInsertIdent(String(idColumn))} as id`, params) as any).execute() | |
| 7288 | const [row] = await (_sql.unsafe(`INSERT INTO ${tbl} (${colsSql}) VALUES ${valuesSql} RETURNING ${quoteInsertIdent(String(idColumn))} as id`, params) as any).execute() | |
| 7267 | 7289 | return row?.id |
| 7268 | 7290 | }, |
| 7269 | 7291 | async updateOrInsert(table, match, values) { |
renamed throughout · 21 hidden lines
| @@ -7272,18 +7294,18 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti renamed throughout | ||
| 7272 | 7294 | let idx = 1 |
| 7273 | 7295 | const whereSql = matchKeys.map(k => `${quoteInsertIdent(k)} = ${getPlaceholder(idx++)}`).join(' AND ') |
| 7274 | 7296 | const whereParams = matchKeys.map(k => (match as any)[k]) |
| 7275 | const existsRows = await (bunSql.unsafe(`SELECT 1 FROM ${tbl} WHERE ${whereSql} LIMIT 1`, whereParams) as any).execute() | |
| 7297 | const existsRows = await (_sql.unsafe(`SELECT 1 FROM ${tbl} WHERE ${whereSql} LIMIT 1`, whereParams) as any).execute() | |
| 7276 | 7298 | if ((existsRows as any[]).length) { |
| 7277 | 7299 | const setKeys = Object.keys(values) |
| 7278 | 7300 | let i = 1 |
| 7279 | 7301 | const setSql = setKeys.map(k => `${quoteInsertIdent(k)} = ${getPlaceholder(i++)}`).join(', ') |
| 7280 | 7302 | const whereSql2 = matchKeys.map(k => `${quoteInsertIdent(k)} = ${getPlaceholder(i++)}`).join(' AND ') |
| 7281 | 7303 | const params = [...setKeys.map(k => (values as any)[k]), ...matchKeys.map(k => (match as any)[k])] |
| 7282 | await (bunSql.unsafe(`UPDATE ${tbl} SET ${setSql} WHERE ${whereSql2}`, params) as any).execute() | |
| 7304 | await (_sql.unsafe(`UPDATE ${tbl} SET ${setSql} WHERE ${whereSql2}`, params) as any).execute() | |
| 7283 | 7305 | return true |
| 7284 | 7306 | } |
| 7285 | 7307 | const { colsSql, valuesSql, params } = buildInsertClause([{ ...match, ...values } as Record<string, any>]) |
| 7286 | await (bunSql.unsafe(`INSERT INTO ${tbl} (${colsSql}) VALUES ${valuesSql}`, params) as any).execute() | |
| 7308 | await (_sql.unsafe(`INSERT INTO ${tbl} (${colsSql}) VALUES ${valuesSql}`, params) as any).execute() | |
| 7287 | 7309 | return true |
| 7288 | 7310 | }, |
| 7289 | 7311 | async upsert(table, rows, conflictColumns, mergeColumns) { |
renamed throughout · 20 hidden lines
| @@ -7301,16 +7323,16 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti renamed throughout | ||
| 7301 | 7323 | // "do nothing" form (an empty SET is a syntax error). See #1035, #1052. |
| 7302 | 7324 | if (isMysqlLike(config.dialect)) { |
| 7303 | 7325 | if (setCols.length === 0) |
| 7304 | return (bunSql.unsafe(`INSERT IGNORE INTO ${tbl} (${colsSql}) VALUES ${valuesSql}`, params) as any).execute() | |
| 7326 | return (_sql.unsafe(`INSERT IGNORE INTO ${tbl} (${colsSql}) VALUES ${valuesSql}`, params) as any).execute() | |
| 7305 | 7327 | const updateList = setCols.map(c => `${quoteInsertIdent(c)} = VALUES(${quoteInsertIdent(c)})`).join(', ') |
| 7306 | return (bunSql.unsafe(`${insert} ON DUPLICATE KEY UPDATE ${updateList}`, params) as any).execute() | |
| 7328 | return (_sql.unsafe(`${insert} ON DUPLICATE KEY UPDATE ${updateList}`, params) as any).execute() | |
| 7307 | 7329 | } |
| 7308 | 7330 | |
| 7309 | 7331 | const targets = targetCols.map(quoteInsertIdent).join(', ') |
| 7310 | 7332 | if (setCols.length === 0) |
| 7311 | return (bunSql.unsafe(`${insert} ON CONFLICT (${targets}) DO NOTHING`, params) as any).execute() | |
| 7333 | return (_sql.unsafe(`${insert} ON CONFLICT (${targets}) DO NOTHING`, params) as any).execute() | |
| 7312 | 7334 | const updateList = setCols.map(c => `${quoteInsertIdent(c)} = EXCLUDED.${quoteInsertIdent(c)}`).join(', ') |
| 7313 | return (bunSql.unsafe(`${insert} ON CONFLICT (${targets}) DO UPDATE SET ${updateList}`, params) as any).execute() | |
| 7335 | return (_sql.unsafe(`${insert} ON CONFLICT (${targets}) DO UPDATE SET ${updateList}`, params) as any).execute() | |
| 7314 | 7336 | }, |
| 7315 | 7337 | async save(table, values) { |
| 7316 | 7338 | const pk = meta?.primaryKeys[String(table)] ?? 'id' |
| @@ -7361,7 +7383,7 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti | ||
| 7361 | 7383 | const start = Date.now() |
| 7362 | 7384 | try { |
| 7363 | 7385 | config.hooks?.onQueryStart?.({ sql: query, kind: 'raw' }) |
| 7364 | const res = await (bunSql as any).unsafe(query) | |
| 7386 | const res = await (_sql as any).unsafe(query) | |
| 7365 | 7387 | config.hooks?.onQueryEnd?.({ sql: query, durationMs: Date.now() - start, kind: 'raw' }) |
| 7366 | 7388 | return res |
| 7367 | 7389 | } |
| @@ -7383,11 +7405,9 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti | ||
| 7383 | 7405 | |
| 7384 | 7406 | if (config.dialect === 'postgres') { |
| 7385 | 7407 | // For PostgreSQL, use RETURNING to get the ID, then fetch the full row |
| 7386 | const q = bunSql`INSERT INTO ${bunSql(String(table))} ${bunSql(values as any)} RETURNING ${bunSql(String(pk))} as id` | |
| 7408 | const q = _sql`INSERT INTO ${_sql(String(table))} ${_sql(values as any)} RETURNING ${_sql(String(pk))} as id` | |
| 7387 | 7409 | const [result] = await q.execute() |
| 7388 | 7410 | |
| 7389 | console.log('resultId', result) | |
| 7390 | ||
| 7391 | 7411 | if (!result?.id) { |
| 7392 | 7412 | console.error(`create() failed to get insert ID for table ${String(table)}`) |
| 7393 | 7413 | console.error('Inserted values:', values) |
| @@ -7600,28 +7620,28 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti | ||
| 7600 | 7620 | return await (this as any).create(table, { ...(match as any), ...(values as any) }) |
| 7601 | 7621 | }, |
| 7602 | 7622 | async count(table, column) { |
| 7603 | const col = column ? bunSql(String(column)) : bunSql`*` | |
| 7604 | const q = bunSql`SELECT COUNT(${col}) as c FROM ${bunSql(String(table))}` | |
| 7623 | const col = column ? _sql(String(column)) : _sql`*` | |
| 7624 | const q = _sql`SELECT COUNT(${col}) as c FROM ${_sql(String(table))}` | |
| 7605 | 7625 | const [row] = await (q as any).execute() |
| 7606 | 7626 | return Number((row?.c ?? 0) as any) |
| 7607 | 7627 | }, |
| 7608 | 7628 | async sum(table, column) { |
| 7609 | const q = bunSql`SELECT SUM(${bunSql(String(column))}) as s FROM ${bunSql(String(table))}` | |
| 7629 | const q = _sql`SELECT SUM(${_sql(String(column))}) as s FROM ${_sql(String(table))}` | |
| 7610 | 7630 | const [row] = await (q as any).execute() |
| 7611 | 7631 | return Number((row?.s ?? 0) as any) |
| 7612 | 7632 | }, |
| 7613 | 7633 | async avg(table, column) { |
| 7614 | const q = bunSql`SELECT AVG(${bunSql(String(column))}) as a FROM ${bunSql(String(table))}` | |
| 7634 | const q = _sql`SELECT AVG(${_sql(String(column))}) as a FROM ${_sql(String(table))}` | |
| 7615 | 7635 | const [row] = await (q as any).execute() |
| 7616 | 7636 | return Number((row?.a ?? 0) as any) |
| 7617 | 7637 | }, |
| 7618 | 7638 | async min(table, column) { |
| 7619 | const q = bunSql`SELECT MIN(${bunSql(String(column))}) as m FROM ${bunSql(String(table))}` | |
| 7639 | const q = _sql`SELECT MIN(${_sql(String(column))}) as m FROM ${_sql(String(table))}` | |
| 7620 | 7640 | const [row] = await (q as any).execute() |
| 7621 | 7641 | return (row?.m as any) |
| 7622 | 7642 | }, |
| 7623 | 7643 | async max(table, column) { |
| 7624 | const q = bunSql`SELECT MAX(${bunSql(String(column))}) as m FROM ${bunSql(String(table))}` | |
| 7644 | const q = _sql`SELECT MAX(${_sql(String(column))}) as m FROM ${_sql(String(table))}` | |
| 7625 | 7645 | const [row] = await (q as any).execute() |
| 7626 | 7646 | return (row?.m as any) |
| 7627 | 7647 | }, |