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 | }, |
| @@ -0,0 +1,112 @@ | ||
| 1 | /** | |
| 2 | * Every statement a builder issues must go through the builder's OWN | |
| 3 | * connection (`_sql`), never the module-global `bunSql`. | |
| 4 | * | |
| 5 | * They are the same object for a plain builder, so this is invisible until a | |
| 6 | * builder is created with a connection of its own — which is exactly what | |
| 7 | * `db.transaction()` does for its callback. Then the global is a DIFFERENT | |
| 8 | * pooled session, and ~29 helper methods were using it: | |
| 9 | * | |
| 10 | * - A write inside `db.transaction()` autocommitted on that other session and | |
| 11 | * SURVIVED THE ROLLBACK. The transaction failed, the caller saw the error, | |
| 12 | * and the row was still in the table. Silent, with no driver error. | |
| 13 | * - A read inside the transaction could not see the transaction's own | |
| 14 | * uncommitted rows, so `tx.count()` returned 0 for a row `tx` had just | |
| 15 | * inserted and read-then-decide logic acted on stale data. | |
| 16 | * | |
| 17 | * SQLite cannot catch either one: `bunSql` and the transaction share a single | |
| 18 | * `bun:sqlite` handle, so the distinction the bug turns on does not exist. It | |
| 19 | * needs a real pooled driver, so this file is gated on a live Postgres and runs | |
| 20 | * in a subprocess (an earlier `configureOrm()` elsewhere in the suite pins the | |
| 21 | * global executor at sqlite and would mask the network path). | |
| 22 | * | |
| 23 | * `unsafe()` was fixed for this once already (client.ts, the comment about raw | |
| 24 | * SQL escaping the transaction); these are the rest of the same family. | |
| 25 | */ | |
| 26 | ||
| 27 | import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' | |
| 28 | import { tmpdir } from 'node:os' | |
| 29 | import { join, resolve } from 'node:path' | |
| 30 | import process from 'node:process' | |
| 31 | import { SQL } from 'bun' | |
| 32 | import { describe, expect, it } from 'bun:test' | |
| 33 | ||
| 34 | const PG_URL = `postgres://${process.env.USER}@localhost:5432/postgres` | |
| 35 | ||
| 36 | let pgAvailable = false | |
| 37 | try { | |
| 38 | const probe = new SQL(PG_URL) | |
| 39 | await probe.unsafe('SELECT 1') | |
| 40 | await probe.end() | |
| 41 | pgAvailable = true | |
| 42 | } | |
| 43 | catch { | |
| 44 | pgAvailable = false | |
| 45 | } | |
| 46 | ||
| 47 | describe.skipIf(!pgAvailable)('builder-local connection routing', () => { | |
| 48 | it('keeps transactional writes inside the transaction, and reads inside it current', () => { | |
| 49 | const srcEntry = resolve(import.meta.dir, '../src/index.ts') | |
| 50 | const dir = mkdtempSync(join(tmpdir(), 'qb-txconn-')) | |
| 51 | const scriptPath = join(dir, 'probe.ts') | |
| 52 | ||
| 53 | writeFileSync(scriptPath, ` | |
| 54 | import { SQL } from 'bun' | |
| 55 | import { setConfig, createQueryBuilder, buildDatabaseSchema } from ${JSON.stringify(srcEntry)} | |
| 56 | ||
| 57 | const URL = ${JSON.stringify(PG_URL)} | |
| 58 | const raw = new SQL(URL) | |
| 59 | await raw.unsafe('DROP TABLE IF EXISTS _qb_txconn') | |
| 60 | await raw.unsafe('CREATE TABLE _qb_txconn (id serial primary key, name text)') | |
| 61 | ||
| 62 | setConfig({ dialect: 'postgres', database: { url: URL } }) | |
| 63 | const schema = buildDatabaseSchema({ | |
| 64 | Widget: { name: 'Widget', table: '_qb_txconn', primaryKey: 'id', attributes: { id: { validation: { rule: {} } }, name: { validation: { rule: {} } } } }, | |
| 65 | }) | |
| 66 | const db = createQueryBuilder({ schema }) | |
| 67 | ||
| 68 | // A write in a transaction that throws must not outlive the ROLLBACK. | |
| 69 | try { | |
| 70 | await db.transaction(async (tx) => { | |
| 71 | await tx.insertGetId('_qb_txconn', { name: 'rolled-back' }) | |
| 72 | throw new Error('deliberate failure') | |
| 73 | }) | |
| 74 | } | |
| 75 | catch {} | |
| 76 | const survivors = await raw.unsafe('SELECT name FROM _qb_txconn') | |
| 77 | ||
| 78 | // A read in a transaction must see that transaction's own uncommitted write. | |
| 79 | let seenInside = -1 | |
| 80 | await db.transaction(async (tx) => { | |
| 81 | await tx.insertGetId('_qb_txconn', { name: 'committed' }) | |
| 82 | seenInside = Number(await tx.count('_qb_txconn')) | |
| 83 | }) | |
| 84 | const committed = await raw.unsafe('SELECT name FROM _qb_txconn') | |
| 85 | ||
| 86 | await raw.unsafe('DROP TABLE IF EXISTS _qb_txconn') | |
| 87 | await raw.end() | |
| 88 | ||
| 89 | console.log(JSON.stringify({ | |
| 90 | survivedRollback: survivors.length, | |
| 91 | seenInside, | |
| 92 | committed: committed.length, | |
| 93 | })) | |
| 94 | `) | |
| 95 | ||
| 96 | const proc = Bun.spawnSync({ cmd: ['bun', scriptPath], cwd: dir, stdout: 'pipe', stderr: 'pipe' }) | |
| 97 | const stdout = new TextDecoder().decode(proc.stdout).trim() | |
| 98 | const stderr = new TextDecoder().decode(proc.stderr).trim() | |
| 99 | rmSync(dir, { recursive: true, force: true }) | |
| 100 | ||
| 101 | if (proc.exitCode !== 0) | |
| 102 | throw new Error(`probe exited ${proc.exitCode}: ${stderr || stdout}`) | |
| 103 | ||
| 104 | const result = JSON.parse(stdout.split('\n').filter(Boolean).at(-1) ?? '{}') | |
| 105 | ||
| 106 | // Was 1 — the write autocommitted on the global connection and outlived the rollback. | |
| 107 | expect(result.survivedRollback).toBe(0) | |
| 108 | // Was 0 — the count ran on the global connection and could not see the tx's row. | |
| 109 | expect(result.seenInside).toBe(1) | |
| 110 | expect(result.committed).toBe(1) | |
| 111 | }) | |
| 112 | }) | |