From 39cb4e28329c8063edea82f23c8825d9ad8f2335 Mon Sep 17 00:00:00 2001 From: Nitjsefnie Date: Sat, 26 Sep 2026 12:33:26 +0200 Subject: [PATCH 1/2] Store fetched array types before running the first query fetchArrayTypes() awaited the types query and only then filled typeArrayMap. The types query's ReadyForQuery resolves it and, in the same synchronous call, goes on to execute(initial), so a new connection's first query was built while the map was still empty and an sql.array() parameter was bound as its element type, e.g. "malformed array literal" with an array cast. Fill the map inside the query's resolve, like fetchState() does, so it is populated before the initial query is built. Fixes #789 Co-Authored-By: Claude Opus 5.5 --- cf/src/connection.js | 6 ++++-- cjs/src/connection.js | 6 ++++-- cjs/tests/index.js | 7 +++++++ deno/src/connection.js | 6 ++++-- deno/tests/index.js | 7 +++++++ src/connection.js | 6 ++++-- tests/index.js | 7 +++++++ 7 files changed, 37 insertions(+), 8 deletions(-) diff --git a/cf/src/connection.js b/cf/src/connection.js index 8e79170a..7ff5b787 100644 --- a/cf/src/connection.js +++ b/cf/src/connection.js @@ -769,7 +769,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose async function fetchArrayTypes() { needsTypes = false - const types = await new Query([` + const query = new Query([` select b.oid, b.typarray from pg_catalog.pg_type a left join pg_catalog.pg_type b on b.oid = a.typelem @@ -777,7 +777,9 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose group by b.oid, b.typarray order by b.oid `], [], execute) - types.forEach(({ oid, typarray }) => addArrayType(oid, typarray)) + const resolve = query.resolve + query.resolve = types => (types.forEach(({ oid, typarray }) => addArrayType(oid, typarray)), resolve(types)) + await query } function addArrayType(oid, typarray) { diff --git a/cjs/src/connection.js b/cjs/src/connection.js index 07f67167..00870cdd 100644 --- a/cjs/src/connection.js +++ b/cjs/src/connection.js @@ -767,7 +767,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose async function fetchArrayTypes() { needsTypes = false - const types = await new Query([` + const query = new Query([` select b.oid, b.typarray from pg_catalog.pg_type a left join pg_catalog.pg_type b on b.oid = a.typelem @@ -775,7 +775,9 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose group by b.oid, b.typarray order by b.oid `], [], execute) - types.forEach(({ oid, typarray }) => addArrayType(oid, typarray)) + const resolve = query.resolve + query.resolve = types => (types.forEach(({ oid, typarray }) => addArrayType(oid, typarray)), resolve(types)) + await query } function addArrayType(oid, typarray) { diff --git a/cjs/tests/index.js b/cjs/tests/index.js index 85d1aa46..725c4356 100644 --- a/cjs/tests/index.js +++ b/cjs/tests/index.js @@ -154,6 +154,13 @@ t('Escape in arrays', async() => ['Hello "you",c:\\windows', (await sql`select ${ sql.array(['Hello "you"', 'c:\\windows']) } as x`)[0].x.join(',')] ) +t('Array in first query of new client', async() => { + const sql = postgres(options) + const x = (await sql`select ${ sql.array([1, 2]) }::int[] as x`)[0].x + await sql.end() + return ['1,2', x.join()] +}) + t('Escapes', async() => { return ['hej"hej', Object.keys((await sql`select 1 as ${ sql('hej"hej') }`)[0])[0]] }) diff --git a/deno/src/connection.js b/deno/src/connection.js index 796725de..a23701b4 100644 --- a/deno/src/connection.js +++ b/deno/src/connection.js @@ -770,7 +770,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose async function fetchArrayTypes() { needsTypes = false - const types = await new Query([` + const query = new Query([` select b.oid, b.typarray from pg_catalog.pg_type a left join pg_catalog.pg_type b on b.oid = a.typelem @@ -778,7 +778,9 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose group by b.oid, b.typarray order by b.oid `], [], execute) - types.forEach(({ oid, typarray }) => addArrayType(oid, typarray)) + const resolve = query.resolve + query.resolve = types => (types.forEach(({ oid, typarray }) => addArrayType(oid, typarray)), resolve(types)) + await query } function addArrayType(oid, typarray) { diff --git a/deno/tests/index.js b/deno/tests/index.js index cc2a2518..a3d4ed23 100644 --- a/deno/tests/index.js +++ b/deno/tests/index.js @@ -156,6 +156,13 @@ t('Escape in arrays', async() => ['Hello "you",c:\\windows', (await sql`select ${ sql.array(['Hello "you"', 'c:\\windows']) } as x`)[0].x.join(',')] ) +t('Array in first query of new client', async() => { + const sql = postgres(options) + const x = (await sql`select ${ sql.array([1, 2]) }::int[] as x`)[0].x + await sql.end() + return ['1,2', x.join()] +}) + t('Escapes', async() => { return ['hej"hej', Object.keys((await sql`select 1 as ${ sql('hej"hej') }`)[0])[0]] }) diff --git a/src/connection.js b/src/connection.js index 10ab1bb3..3e79bd0e 100644 --- a/src/connection.js +++ b/src/connection.js @@ -767,7 +767,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose async function fetchArrayTypes() { needsTypes = false - const types = await new Query([` + const query = new Query([` select b.oid, b.typarray from pg_catalog.pg_type a left join pg_catalog.pg_type b on b.oid = a.typelem @@ -775,7 +775,9 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose group by b.oid, b.typarray order by b.oid `], [], execute) - types.forEach(({ oid, typarray }) => addArrayType(oid, typarray)) + const resolve = query.resolve + query.resolve = types => (types.forEach(({ oid, typarray }) => addArrayType(oid, typarray)), resolve(types)) + await query } function addArrayType(oid, typarray) { diff --git a/tests/index.js b/tests/index.js index 845c6ce0..855d101b 100644 --- a/tests/index.js +++ b/tests/index.js @@ -154,6 +154,13 @@ t('Escape in arrays', async() => ['Hello "you",c:\\windows', (await sql`select ${ sql.array(['Hello "you"', 'c:\\windows']) } as x`)[0].x.join(',')] ) +t('Array in first query of new client', async() => { + const sql = postgres(options) + const x = (await sql`select ${ sql.array([1, 2]) }::int[] as x`)[0].x + await sql.end() + return ['1,2', x.join()] +}) + t('Escapes', async() => { return ['hej"hej', Object.keys((await sql`select 1 as ${ sql('hej"hej') }`)[0])[0]] }) From 0b40f6122a71d94854146d87840c1a79e8bee80c Mon Sep 17 00:00:00 2001 From: Nitjsefnie Date: Sat, 26 Sep 2026 14:16:38 +0200 Subject: [PATCH 2/2] Close the connection when fetching array types fails ReadyForQuery discarded the promise returned by fetchArrayTypes(), so when the types query failed, e.g. a role that cannot read pg_type or a statement_timeout cancelling it, its rejection went unhandled and terminated the process. errored() had already rejected the waiting query, and the connection then went on to onopen() without array types, so the next sql.array() query on it was bound as its element type. A reserve waiting on the connection was even handed that connection. Settle the failure synchronously in the types query's reject instead: it marks the types as still needed and rejects a waiting reserve with the same error. ReadyForQuery then closes the connection rather than opening it, so queued queries go to a new connection that fetches the types again, and the awaited query is caught since nothing is left for it to report. Fixes #1192 Co-Authored-By: Claude Opus 5.5 --- cf/src/connection.js | 14 ++++++++--- cjs/src/connection.js | 14 ++++++++--- cjs/tests/index.js | 56 ++++++++++++++++++++++++++++++++++++++++++ deno/src/connection.js | 14 ++++++++--- deno/tests/index.js | 56 ++++++++++++++++++++++++++++++++++++++++++ src/connection.js | 14 ++++++++--- tests/index.js | 56 ++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 208 insertions(+), 16 deletions(-) diff --git a/cf/src/connection.js b/cf/src/connection.js index 7ff5b787..833094d6 100644 --- a/cf/src/connection.js +++ b/cf/src/connection.js @@ -562,8 +562,9 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose } if (needsTypes) { - initial.reserve && (initial = null) - return fetchArrayTypes() + const reserve = initial.reserve ? initial : null + reserve && (initial = null) + return fetchArrayTypes(reserve) } initial && !initial.reserve && execute(initial) @@ -572,6 +573,9 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose return } + if (needsTypes) + return terminate() + while (sent.length && (query = sent.shift()) && (query.active = true, query.cancelled)) Connection(options).cancel(query.state, query.cancelled.resolve, query.cancelled.reject) @@ -767,7 +771,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose backend.secret = x.readUInt32BE(9) } - async function fetchArrayTypes() { + async function fetchArrayTypes(reserve) { needsTypes = false const query = new Query([` select b.oid, b.typarray @@ -779,7 +783,9 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose `], [], execute) const resolve = query.resolve query.resolve = types => (types.forEach(({ oid, typarray }) => addArrayType(oid, typarray)), resolve(types)) - await query + const reject = query.reject + query.reject = err => (needsTypes = true, reserve && reserve.reject(err), reject(err)) + await query.catch(() => { /* settled by query.reject above; ReadyForQuery closes the connection */ }) } function addArrayType(oid, typarray) { diff --git a/cjs/src/connection.js b/cjs/src/connection.js index 00870cdd..a29bf703 100644 --- a/cjs/src/connection.js +++ b/cjs/src/connection.js @@ -560,8 +560,9 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose } if (needsTypes) { - initial.reserve && (initial = null) - return fetchArrayTypes() + const reserve = initial.reserve ? initial : null + reserve && (initial = null) + return fetchArrayTypes(reserve) } initial && !initial.reserve && execute(initial) @@ -570,6 +571,9 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose return } + if (needsTypes) + return terminate() + while (sent.length && (query = sent.shift()) && (query.active = true, query.cancelled)) Connection(options).cancel(query.state, query.cancelled.resolve, query.cancelled.reject) @@ -765,7 +769,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose backend.secret = x.readUInt32BE(9) } - async function fetchArrayTypes() { + async function fetchArrayTypes(reserve) { needsTypes = false const query = new Query([` select b.oid, b.typarray @@ -777,7 +781,9 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose `], [], execute) const resolve = query.resolve query.resolve = types => (types.forEach(({ oid, typarray }) => addArrayType(oid, typarray)), resolve(types)) - await query + const reject = query.reject + query.reject = err => (needsTypes = true, reserve && reserve.reject(err), reject(err)) + await query.catch(() => { /* settled by query.reject above; ReadyForQuery closes the connection */ }) } function addArrayType(oid, typarray) { diff --git a/cjs/tests/index.js b/cjs/tests/index.js index 725c4356..5e4f431c 100644 --- a/cjs/tests/index.js +++ b/cjs/tests/index.js @@ -161,6 +161,62 @@ t('Array in first query of new client', async() => { return ['1,2', x.join()] }) +async function withoutArrayTypes(fn) { + const unhandled = [] + , onUnhandled = e => unhandled.push(e.code) + , grant = () => exec('psql', ['-d', 'postgres_js_test', '-c', 'grant select on pg_catalog.pg_type to public']) + + // Deno's process.on('unhandledRejection') is a stub, but Deno exits on an unhandled rejection anyway + globalThis.Deno || process.on('unhandledRejection', onUnhandled) + await exec('psql', ['-c', 'drop user if exists postgres_js_test_no_types']) + await exec('psql', ['-c', 'create user postgres_js_test_no_types']) + await exec('psql', ['-d', 'postgres_js_test', '-c', 'revoke select on pg_catalog.pg_type from public']) + + const sql = postgres({ + ...options, + user: 'postgres_js_test_no_types', + host: process.env.PGSOCKET || '/tmp' // eslint-disable-line + }) + + try { + const result = await fn({ sql, grant }) + await delay(0) // Node reports a rejection left unhandled before the next timer + return JSON.stringify({ ...result, unhandled }) + } finally { + await grant() + globalThis.Deno || process.off('unhandledRejection', onUnhandled) + await sql.end({ timeout: 0 }) + await exec('psql', ['-c', 'drop user postgres_js_test_no_types']) + } +} + +t('Failed array types fetch rejects the first query and keeps the client usable', async() => [ + '{"first":"42501","later":"1,2","unhandled":[]}', + await withoutArrayTypes(async({ sql, grant }) => { + const first = await sql`select 1`.catch(e => e.code) + await grant() + const later = await sql`select ${ sql.array([1, 2]) }::int[] as x`.then(([{ x }]) => x.join(), e => e.code) + return { first, later } + }) +]) + +t('Failed array types fetch rejects every query waiting on the connection', async() => [ + '{"waiting":["42501","42501"],"unhandled":[]}', + await withoutArrayTypes(async({ sql }) => ({ + waiting: await Promise.all([ + sql`select 1`.catch(e => e.code), + sql`select ${ sql.array([3, 4]) }::int[] as x`.catch(e => e.code) + ]) + })) +]) + +t('Failed array types fetch rejects a waiting reserve', async() => [ + '{"reserved":"42501","unhandled":[]}', + await withoutArrayTypes(async({ sql }) => ({ + reserved: await sql.reserve().then(r => (r.release(), 'reserved'), e => e.code) + })) +]) + t('Escapes', async() => { return ['hej"hej', Object.keys((await sql`select 1 as ${ sql('hej"hej') }`)[0])[0]] }) diff --git a/deno/src/connection.js b/deno/src/connection.js index a23701b4..0e4367ec 100644 --- a/deno/src/connection.js +++ b/deno/src/connection.js @@ -563,8 +563,9 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose } if (needsTypes) { - initial.reserve && (initial = null) - return fetchArrayTypes() + const reserve = initial.reserve ? initial : null + reserve && (initial = null) + return fetchArrayTypes(reserve) } initial && !initial.reserve && execute(initial) @@ -573,6 +574,9 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose return } + if (needsTypes) + return terminate() + while (sent.length && (query = sent.shift()) && (query.active = true, query.cancelled)) Connection(options).cancel(query.state, query.cancelled.resolve, query.cancelled.reject) @@ -768,7 +772,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose backend.secret = x.readUInt32BE(9) } - async function fetchArrayTypes() { + async function fetchArrayTypes(reserve) { needsTypes = false const query = new Query([` select b.oid, b.typarray @@ -780,7 +784,9 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose `], [], execute) const resolve = query.resolve query.resolve = types => (types.forEach(({ oid, typarray }) => addArrayType(oid, typarray)), resolve(types)) - await query + const reject = query.reject + query.reject = err => (needsTypes = true, reserve && reserve.reject(err), reject(err)) + await query.catch(() => { /* settled by query.reject above; ReadyForQuery closes the connection */ }) } function addArrayType(oid, typarray) { diff --git a/deno/tests/index.js b/deno/tests/index.js index a3d4ed23..4a321279 100644 --- a/deno/tests/index.js +++ b/deno/tests/index.js @@ -163,6 +163,62 @@ t('Array in first query of new client', async() => { return ['1,2', x.join()] }) +async function withoutArrayTypes(fn) { + const unhandled = [] + , onUnhandled = e => unhandled.push(e.code) + , grant = () => exec('psql', ['-d', 'postgres_js_test', '-c', 'grant select on pg_catalog.pg_type to public']) + + // Deno's process.on('unhandledRejection') is a stub, but Deno exits on an unhandled rejection anyway + globalThis.Deno || process.on('unhandledRejection', onUnhandled) + await exec('psql', ['-c', 'drop user if exists postgres_js_test_no_types']) + await exec('psql', ['-c', 'create user postgres_js_test_no_types']) + await exec('psql', ['-d', 'postgres_js_test', '-c', 'revoke select on pg_catalog.pg_type from public']) + + const sql = postgres({ + ...options, + user: 'postgres_js_test_no_types', + host: process.env.PGSOCKET || '/tmp' // eslint-disable-line + }) + + try { + const result = await fn({ sql, grant }) + await delay(0) // Node reports a rejection left unhandled before the next timer + return JSON.stringify({ ...result, unhandled }) + } finally { + await grant() + globalThis.Deno || process.off('unhandledRejection', onUnhandled) + await sql.end({ timeout: 0 }) + await exec('psql', ['-c', 'drop user postgres_js_test_no_types']) + } +} + +t('Failed array types fetch rejects the first query and keeps the client usable', async() => [ + '{"first":"42501","later":"1,2","unhandled":[]}', + await withoutArrayTypes(async({ sql, grant }) => { + const first = await sql`select 1`.catch(e => e.code) + await grant() + const later = await sql`select ${ sql.array([1, 2]) }::int[] as x`.then(([{ x }]) => x.join(), e => e.code) + return { first, later } + }) +]) + +t('Failed array types fetch rejects every query waiting on the connection', async() => [ + '{"waiting":["42501","42501"],"unhandled":[]}', + await withoutArrayTypes(async({ sql }) => ({ + waiting: await Promise.all([ + sql`select 1`.catch(e => e.code), + sql`select ${ sql.array([3, 4]) }::int[] as x`.catch(e => e.code) + ]) + })) +]) + +t('Failed array types fetch rejects a waiting reserve', async() => [ + '{"reserved":"42501","unhandled":[]}', + await withoutArrayTypes(async({ sql }) => ({ + reserved: await sql.reserve().then(r => (r.release(), 'reserved'), e => e.code) + })) +]) + t('Escapes', async() => { return ['hej"hej', Object.keys((await sql`select 1 as ${ sql('hej"hej') }`)[0])[0]] }) diff --git a/src/connection.js b/src/connection.js index 3e79bd0e..d8d0ee30 100644 --- a/src/connection.js +++ b/src/connection.js @@ -560,8 +560,9 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose } if (needsTypes) { - initial.reserve && (initial = null) - return fetchArrayTypes() + const reserve = initial.reserve ? initial : null + reserve && (initial = null) + return fetchArrayTypes(reserve) } initial && !initial.reserve && execute(initial) @@ -570,6 +571,9 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose return } + if (needsTypes) + return terminate() + while (sent.length && (query = sent.shift()) && (query.active = true, query.cancelled)) Connection(options).cancel(query.state, query.cancelled.resolve, query.cancelled.reject) @@ -765,7 +769,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose backend.secret = x.readUInt32BE(9) } - async function fetchArrayTypes() { + async function fetchArrayTypes(reserve) { needsTypes = false const query = new Query([` select b.oid, b.typarray @@ -777,7 +781,9 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose `], [], execute) const resolve = query.resolve query.resolve = types => (types.forEach(({ oid, typarray }) => addArrayType(oid, typarray)), resolve(types)) - await query + const reject = query.reject + query.reject = err => (needsTypes = true, reserve && reserve.reject(err), reject(err)) + await query.catch(() => { /* settled by query.reject above; ReadyForQuery closes the connection */ }) } function addArrayType(oid, typarray) { diff --git a/tests/index.js b/tests/index.js index 855d101b..1e38f2f2 100644 --- a/tests/index.js +++ b/tests/index.js @@ -161,6 +161,62 @@ t('Array in first query of new client', async() => { return ['1,2', x.join()] }) +async function withoutArrayTypes(fn) { + const unhandled = [] + , onUnhandled = e => unhandled.push(e.code) + , grant = () => exec('psql', ['-d', 'postgres_js_test', '-c', 'grant select on pg_catalog.pg_type to public']) + + // Deno's process.on('unhandledRejection') is a stub, but Deno exits on an unhandled rejection anyway + globalThis.Deno || process.on('unhandledRejection', onUnhandled) + await exec('psql', ['-c', 'drop user if exists postgres_js_test_no_types']) + await exec('psql', ['-c', 'create user postgres_js_test_no_types']) + await exec('psql', ['-d', 'postgres_js_test', '-c', 'revoke select on pg_catalog.pg_type from public']) + + const sql = postgres({ + ...options, + user: 'postgres_js_test_no_types', + host: process.env.PGSOCKET || '/tmp' // eslint-disable-line + }) + + try { + const result = await fn({ sql, grant }) + await delay(0) // Node reports a rejection left unhandled before the next timer + return JSON.stringify({ ...result, unhandled }) + } finally { + await grant() + globalThis.Deno || process.off('unhandledRejection', onUnhandled) + await sql.end({ timeout: 0 }) + await exec('psql', ['-c', 'drop user postgres_js_test_no_types']) + } +} + +t('Failed array types fetch rejects the first query and keeps the client usable', async() => [ + '{"first":"42501","later":"1,2","unhandled":[]}', + await withoutArrayTypes(async({ sql, grant }) => { + const first = await sql`select 1`.catch(e => e.code) + await grant() + const later = await sql`select ${ sql.array([1, 2]) }::int[] as x`.then(([{ x }]) => x.join(), e => e.code) + return { first, later } + }) +]) + +t('Failed array types fetch rejects every query waiting on the connection', async() => [ + '{"waiting":["42501","42501"],"unhandled":[]}', + await withoutArrayTypes(async({ sql }) => ({ + waiting: await Promise.all([ + sql`select 1`.catch(e => e.code), + sql`select ${ sql.array([3, 4]) }::int[] as x`.catch(e => e.code) + ]) + })) +]) + +t('Failed array types fetch rejects a waiting reserve', async() => [ + '{"reserved":"42501","unhandled":[]}', + await withoutArrayTypes(async({ sql }) => ({ + reserved: await sql.reserve().then(r => (r.release(), 'reserved'), e => e.code) + })) +]) + t('Escapes', async() => { return ['hej"hej', Object.keys((await sql`select 1 as ${ sql('hej"hej') }`)[0])[0]] })