From 23e9ead03bbcbef974bba5d8156ebcdf1f89c512 Mon Sep 17 00:00:00 2001 From: kaijakagi-sys Date: Tue, 8 Sep 2026 02:54:24 +0000 Subject: [PATCH 1/4] fix: close Hyperdrive client when query fails --- src/operation.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/operation.ts b/src/operation.ts index 4abc0dd..e28709d 100644 --- a/src/operation.ts +++ b/src/operation.ts @@ -276,7 +276,10 @@ export async function executeQuery(opts: { try { result = await sql.unsafe(updatedSQL, updatedParams as any[]) - + } catch (e) { + console.error('Hyperdrive query error:', e) + throw e + } finally { if (opts.dataSource?.executionContext) { // Optimistically we hope a ExecutionContext is available to us // to properly end our SQL function. @@ -285,9 +288,6 @@ export async function executeQuery(opts: { // As a fallback we'll just end it. await sql.end() } - } catch (e) { - console.error('Hyperdrive query error:', e) - throw e } } else { result = await executeExternalQuery({ From bec45944ec3464ec1e2364a143c213f659bf4ed7 Mon Sep 17 00:00:00 2001 From: kaijakagi-sys Date: Tue, 8 Sep 2026 02:54:25 +0000 Subject: [PATCH 2/4] test: cover Hyperdrive cleanup on query rejection --- src/operation.hyperdrive.test.ts | 78 ++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 src/operation.hyperdrive.test.ts diff --git a/src/operation.hyperdrive.test.ts b/src/operation.hyperdrive.test.ts new file mode 100644 index 0000000..b5ad1e9 --- /dev/null +++ b/src/operation.hyperdrive.test.ts @@ -0,0 +1,78 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { executeQuery } from './operation' +import type { DataSource } from './types' +import type { StarbaseDBConfiguration } from './handler' + +const client = vi.hoisted(() => ({ unsafe: vi.fn(), end: vi.fn() })) + +vi.mock('postgres', () => ({ default: vi.fn(() => client) })) +vi.mock('./allowlist', () => ({ isQueryAllowed: vi.fn() })) +vi.mock('./rls', () => ({ applyRLS: vi.fn(async ({ sql }) => sql) })) +vi.mock('./cache', () => ({ + beforeQueryCache: vi.fn(async () => null), + afterQueryCache: vi.fn(), +})) + +beforeEach(() => { + vi.clearAllMocks() + client.unsafe.mockReset().mockResolvedValue([{ id: 1 }]) + client.end.mockReset().mockResolvedValue(undefined) + vi.spyOn(console, 'error').mockImplementation(() => {}) +}) + +afterEach(() => vi.restoreAllMocks()) + +describe('Hyperdrive connection lifecycle', () => { + for (const background of [false, true]) { + const mode = background ? 'with waitUntil' : 'without waitUntil' + + function run(waitUntil: ReturnType) { + return executeQuery({ + sql: 'SELECT id FROM records WHERE id = $1', + params: [1], + isRaw: false, + dataSource: { + source: 'hyperdrive', + external: { connectionString: 'postgres://localhost/test' }, + ...(background ? { executionContext: { waitUntil } } : {}), + } as unknown as DataSource, + config: { + role: 'admin', + features: {}, + } as StarbaseDBConfiguration, + }) + } + + it(`closes the client after success ${mode}`, async () => { + const waitUntil = vi.fn() + await expect(run(waitUntil)).resolves.toEqual([{ id: 1 }]) + expect(client.unsafe).toHaveBeenCalledWith( + 'SELECT id FROM records WHERE id = $1', + [1] + ) + expect(client.end).toHaveBeenCalledTimes(1) + if (background) { + expect(waitUntil).toHaveBeenCalledWith( + client.end.mock.results[0].value + ) + } else { + expect(waitUntil).not.toHaveBeenCalled() + } + }) + + it(`closes the client and preserves query failure ${mode}`, async () => { + const failure = new Error('query failed') + client.unsafe.mockRejectedValueOnce(failure) + const waitUntil = vi.fn() + await expect(run(waitUntil)).rejects.toBe(failure) + expect(client.end).toHaveBeenCalledTimes(1) + if (background) { + expect(waitUntil).toHaveBeenCalledWith( + client.end.mock.results[0].value + ) + } else { + expect(waitUntil).not.toHaveBeenCalled() + } + }) + } +}) From b858ecf45147f7de32c87206873b3b469dd64558 Mon Sep 17 00:00:00 2001 From: kaijakagi-sys Date: Tue, 8 Sep 2026 03:35:27 +0000 Subject: [PATCH 3/4] fix: preserve query rejection when Hyperdrive cleanup fails --- src/operation.ts | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/src/operation.ts b/src/operation.ts index e28709d..11aff51 100644 --- a/src/operation.ts +++ b/src/operation.ts @@ -274,19 +274,27 @@ export async function executeQuery(opts: { fetch_types: false, }) + let queryFailed = false try { result = await sql.unsafe(updatedSQL, updatedParams as any[]) } catch (e) { + queryFailed = true console.error('Hyperdrive query error:', e) throw e } finally { - if (opts.dataSource?.executionContext) { - // Optimistically we hope a ExecutionContext is available to us - // to properly end our SQL function. - opts.dataSource?.executionContext?.waitUntil(sql.end()) - } else { - // As a fallback we'll just end it. - await sql.end() + try { + if (opts.dataSource?.executionContext) { + // Optimistically we hope a ExecutionContext is available to us + // to properly end our SQL function. + opts.dataSource?.executionContext?.waitUntil(sql.end()) + } else { + // As a fallback we'll just end it. + await sql.end() + } + } catch (e) { + console.error('Hyperdrive cleanup error:', e) + // A cleanup failure must not replace the original query rejection. + if (!queryFailed) throw e } } } else { From 958168e4086f5b1b0f0dbe75400193eb06c5c93a Mon Sep 17 00:00:00 2001 From: kaijakagi-sys Date: Tue, 8 Sep 2026 03:35:40 +0000 Subject: [PATCH 4/4] test: cover simultaneous Hyperdrive query and cleanup failures --- src/operation.hyperdrive.test.ts | 47 ++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/src/operation.hyperdrive.test.ts b/src/operation.hyperdrive.test.ts index b5ad1e9..d03b8ed 100644 --- a/src/operation.hyperdrive.test.ts +++ b/src/operation.hyperdrive.test.ts @@ -74,5 +74,52 @@ describe('Hyperdrive connection lifecycle', () => { expect(waitUntil).not.toHaveBeenCalled() } }) + + it.each([new Error('query failed'), undefined])( + `preserves query rejection %s when cleanup also rejects ${mode}`, + async (failure) => { + const cleanupFailure = new Error('cleanup failed') + client.unsafe.mockRejectedValueOnce(failure) + client.end.mockRejectedValueOnce(cleanupFailure) + // Model the runtime observing background rejection, without + // leaving an unhandled promise rejection in the test process. + const waitUntil = vi.fn((promise: Promise) => { + void promise.catch(() => {}) + }) + + await expect(run(waitUntil)).rejects.toBe(failure) + expect(client.end).toHaveBeenCalledTimes(1) + if (background) { + const cleanup = client.end.mock.results[0].value + expect(waitUntil).toHaveBeenCalledWith(cleanup) + await expect(cleanup).rejects.toBe(cleanupFailure) + } else { + expect(waitUntil).not.toHaveBeenCalled() + expect(console.error).toHaveBeenCalledWith( + 'Hyperdrive cleanup error:', + cleanupFailure + ) + } + } + ) + + it(`reports cleanup failure after query success ${mode}`, async () => { + const cleanupFailure = new Error('cleanup failed') + client.end.mockRejectedValueOnce(cleanupFailure) + const waitUntil = vi.fn((promise: Promise) => { + void promise.catch(() => {}) + }) + + if (background) { + await expect(run(waitUntil)).resolves.toEqual([{ id: 1 }]) + const cleanup = client.end.mock.results[0].value + expect(waitUntil).toHaveBeenCalledWith(cleanup) + await expect(cleanup).rejects.toBe(cleanupFailure) + } else { + await expect(run(waitUntil)).rejects.toBe(cleanupFailure) + expect(waitUntil).not.toHaveBeenCalled() + } + expect(client.end).toHaveBeenCalledTimes(1) + }) } })