Skip to content

Add sql.listen() and sql.notify() for PostgreSQL LISTEN/NOTIFY - #32089

Merged
cirospaciari merged 15 commits into
mainfrom
farm/fce8bc84/pg-listen-notify
Aug 14, 2026
Merged

Add sql.listen() and sql.notify() for PostgreSQL LISTEN/NOTIFY#32089
cirospaciari merged 15 commits into
mainfrom
farm/fce8bc84/pg-listen-notify

Conversation

@robobun

@robobun robobun commented Jun 11, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

Adds sql.listen() and sql.notify() for PostgreSQL (LISTEN/NOTIFY). Originally #29710 by @versecafe; this is a from-scratch rewrite of both the native and the JS side, rebased onto current main (design and numbers below, the earlier implementation is referred to as v1).

import { sql } from "bun";

const subscription = await sql.listen("orders", payload => {
  console.log("new order", JSON.parse(payload));
});

await sql.notify("orders", JSON.stringify({ id: 42 }));
await subscription.unlisten();

// or scoped:
await using subscription = await sql.listen("orders", handleOrder);
  • listen(channel, onnotify, onlisten?) resolves once the server acknowledged the LISTEN, with a ListenSubscription (channel, unlisten(), Symbol.asyncDispose) that removes exactly the registration that call made. Every call is its own registration; several on one channel share one server-side LISTEN. onlisten() runs on the acknowledgement and again after every reconnect (the place to catch up on missed notifications). Shape per the review discussion below; there is no sql.unlisten() and no pid/secret exposure.
  • notify(channel, payload?) is SELECT pg_notify($1, $2) on the handle it is called on, so tx.notify() is delivered on commit and dropped on rollback. Reserved and transaction handles have both methods.
  • All subscriptions of a client share one dedicated connection, opened by the first listen() and closed when the last subscription is removed; while anything is subscribed it keeps the process alive (sql.listen() subscription does not keep the process alive while waiting for notifications #32127). If it drops, it reconnects with jittered exponential backoff (250ms to 32s) and re-subscribes every channel. sql.close() tears it down, including mid-handshake.
  • Channel names are quoted as identifiers and limited to PostgreSQL's 63 identifier bytes; a throwing callback is reported as an uncaught exception. Non-Postgres adapters reject with "PostgreSQL only".

Design

Hot path (one NotificationResponse), native side in PostgresSQLConnection.rs:

  1. NotificationResponse::decode yields the channel and payload as slices into the read buffer (string_within), nothing is copied. v1 copied both into Vecs.
  2. The channel slice is looked up in a small per-connection table of interned JSStrings (Strong, at most 256 entries), so after the first notification on a channel the channel name costs a scan of that table and no allocation. v1 created a new string every time.
  3. The payload becomes one JSString (create_utf8_for_js, the string owns the only copy).
  4. The JS callback is invoked synchronously with run_callback, the same way query results are delivered; exceptions surface as uncaught. v1 queued a microtask per notification.

ref() on a connection now sets KEEP_ALIVE_REQUESTED so the idle-unref at the end of on_data leaves the listen connection ref'd.

JS side (src/js/internal/sql/postgres.ts): one ListenConnection per adapter holding a Map<channel, Channel> plus the connection, sweep timer and backoff fields; a ListenSubscription only remembers its channel and callbacks and hands them back on unlisten(). Dispatch is Map.get followed by a direct call (a lone listener is stored as the bare function; several are stored in an array that is replaced rather than mutated, so listen()/unlisten() from inside a callback cannot disturb the dispatch in progress). A Channel entry's identity scopes its LISTEN round trip: removing the last registration deletes the entry synchronously, and anything resuming after an await compares its entry against the map, which removes the re-add / unlisten-during-ack races v1 needed separate bookkeeping for. There is exactly one repair mechanism: a connection drop, connect failure or rejected LISTEN nulls the affected entries' ready promise and arms #scheduleSweep(), which re-issues LISTEN for those entries. v1 kept five channel-keyed maps and sets (listeners, onlisten callbacks, in-flight round trips, channels registered on the current connection, failure counters) consistent by hand for the same job.

Numbers

Both builds are release builds of this branch's base with the respective implementation applied, measured against a scripted backend in a separate process that writes pre-built frames (it sustains over 10M frames/s into a sink, so the client is what is measured). 4 channels, medians of 5 runs of 1M notifications, two passes each.

payload v1 this PR
32 bytes 3.65M to 4.10M notifications/s (244 to 274 ns each) 4.91M to 5.37M notifications/s (186 to 204 ns each)
256 bytes 2.74M to 2.83M/s 2.86M to 2.89M/s (both bounded by socket reads on this machine)

JS heap objects per notification (bun:jsc heapStats() delta across a blast with no collection in between): v1 allocated 2 strings plus a Set iterator (until the JIT elides it), this PR allocates the 1 payload string. Native allocations per notification went from two Vecs plus two string buffers plus a microtask entry to one string buffer.

How did you verify your code works?

test/js/sql/postgres-listen-notify.test.ts, 56 tests:

  • 44 run against a scripted backend in the test process (which can hold a LISTEN ack, fail one, or drop the connection on demand) or in a subprocess: routing and ordering, thousands of notifications in one read, UTF-8 and quoting, the 63-byte limit, listeners added or removed from inside a callback, shared round trips, the same callback registered twice being two subscriptions, onlisten timing, unlisten() and await using removing exactly their own registration (idempotent, and a no-op on a handle that outlived close()), a re-listen while the UNLISTEN is in flight, reconnect (re-subscribe, an unlistened subscription's onlisten no longer firing, retry with warning, cancelled by unlistening, a shared listen() whose LISTEN is rejected still repairing the other listeners), close() during a round trip, during the handshake and in the same tick as a listen() on the live connection, argument validation on Postgres and SQLite, notify() going through the pool, and in a subprocess: the subscription keeping the process alive (also across the backoff), close() letting it exit, throwing onnotify and onlisten callbacks reaching uncaughtException without affecting the other listeners, and an RSS check over 2 x 24 MiB of payloads that fails when one string per notification is leaked (verified by injecting that leak).
  • 12 run against the docker postgres: payload round trips including empty and omitted payloads, commit/rollback delivery, and the ten LISTEN/NOTIFY tests of postgres.js's own suite ported under their original names (weird and upper-case channel names, double listen, unlisten variants, reconnect after the listening backend is terminated), with their delays replaced by waiting for the deliveries. These also pass against a local PostgreSQL 17.

All of them fail on main (sql.listen is not a function).

Fixes #18214
Fixes #32127


[review] gate passed · iteration 30 · 11 files touched

fails on main (without fix)
ASAN without fix: 44 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/sql/postgres-listen-notify.test.ts
bun test v1.4.0 (915989f6e)

test/js/sql/postgres-listen-notify.test.ts:
failed to connect to the docker API at unix:///var/run/docker.sock; check if the path is correct and if the daemon is running: dial unix /var/run/docker.sock: connect: no such file or directory
149 |     await using sql = client(server.url);
150 |     const got: string[] = [];
151 |     const third = gate();
152 |     const count = third.after(3);
153 | 
154 |     const subscription = await sql.listen("orders", payload => {
                                         ^
TypeError: sql.listen is not a function. (In 'sql.listen("orders", (payload) => {
      got.push(payload);
      count();
    })', 'sql.listen' is undefined)
      at <anonymous> (/workspace/bun/test/js/sql/postgres-listen-notify.test.ts:154:36)
(fail) listen > routes notifications to the channel's listener, in order [243.12ms]
177 |     const count = done.after(total);
178 |     const listener = (channel: keyof typeof counts) => () => {
179 |       counts[cha
... (truncated)

release without fix: all passed
bun test v1.4.0-canary.1 (06ec075fc)

test/js/sql/postgres-listen-notify.test.ts:
failed to connect to the docker API at unix:///var/run/docker.sock; check if the path is correct and if the daemon is running: dial unix /var/run/docker.sock: connect: no such file or directory
(pass) listen > routes notifications to the channel's listener, in order [19.61ms]
(pass) listen > thousands of notifications across channels arriving in one read [11.79ms]
(pass) listen > channel names and payloads are UTF-8 [2.00ms]
(pass) listen > channel names are quoted as identifiers [1.94ms]
(pass) listen > channel names are limited to PostgreSQL's 63 identifier bytes, counted in UTF-8 [2.79ms]
(pass) listen > several listeners on one channel share one LISTEN and each receives [2.13ms]
(pass) listen > concurrent listen() calls on a new channel share its round trip [2.06ms]
(pass) listen > a listener that unlistens itself mid-dispatch does not starve the others [3.26ms]
(pass) listen > a listener added from inside a callback receives the next notification, not the current one [2.05ms]
(pass) listen > registering the same callback twice is two subscriptions, each removed by its own handle [
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/sql/postgres-listen-notify.test.ts
bun test v1.4.0 (915989f6e)

test/js/sql/postgres-listen-notify.test.ts:
failed to connect to the docker API at unix:///var/run/docker.sock; check if the path is correct and if the daemon is running: dial unix /var/run/docker.sock: connect: no such file or directory
(pass) listen > routes notifications to the channel's listener, in order [583.97ms]
(pass) listen > thousands of notifications across channels arriving in one read [1465.10ms]
(pass) listen > channel names and payloads are UTF-8 [50.49ms]
(pass) listen > channel names are quoted as identifiers [43.60ms]
(pass) listen > channel names are limited to PostgreSQL's 63 identifier bytes, counted in UTF-8 [57.35ms]
(pass) listen > several listeners on one channel share one LISTEN and each receives [53.34ms]
(pass) listen > concurrent listen() calls on a new channel share its round trip [48.07ms]
(pass) listen > a listener that unlistens itself mid-dispatch does not starve the others [61.40ms]
(pass) listen > a listener added from inside a 
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 678ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/30] gen ZigGeneratedClasses.{cpp,h,rs}
Found 2 classes from /workspace/bun/src/jsc/resolve_message.classes.ts
  - ResolveMessage (15 fields)
  - BuildMessage (10 fields)
Found 1 classes from /workspace/bun/src/runtime/api/Archive.classes.ts
  - Archive (4 fields, 1 class fields)
Found 2 classes from /workspace/bun/src/runtime/api/BunObject.classes.ts
  - ResourceUsage (8 fields)
  - Subprocess (20 fields)
Found 1 classes from /workspace/bun/src/runtime/api/cron.classes.ts
  - CronJob (5 fields)
Found 3 classes from /workspace/bun/src/runtime/api/filesystem_router.classes.ts
  - FileSystemRouter (5 fields)
  - FrameworkFileSystemRouter (2 fields)
  - MatchedRoute (8 fields)
Found 1 classes from /workspace/bun/src/runtime/api/Glob.classes.ts
  - Glob (5 fields)
Found 1 classes from /workspace/bun/src/runtime/api/h2.classes.ts
  - H2FrameParser (32 fields)
Found 9 classes from /workspace/bun/src/runtime/api/html_rewriter.classes.ts
  - HTMLRewriter (3 fields)
  - TextChunk (7 fields)
  - DocType (5 fields)
... (truncated)
diff hotspot
docs/runtime/sql.mdx                              |   60 +-
 packages/bun-types/sql.d.ts                       |   62 ++
 src/js/bun/sql.ts                                 |   51 +
 src/js/internal/sql/postgres.ts                   |  336 ++++++
 src/js/internal/sql/shared.ts                     |   13 +-
 src/runtime/api/sql.classes.ts                    |   14 +-
 src/sql/postgres/protocol/NotificationResponse.rs |   16 +-
 src/sql/shared/ConnectionFlags.rs                 |    2 +
 src/sql_jsc/jsc.rs                                |    4 +-
 src/sql_jsc/postgres/PostgresSQLConnection.rs     |   92 +-
 test/js/sql/postgres-listen-notify.test.ts        | 1238 +++++++++++++++++++++
 11 files changed, 1863 insertions(+), 25 deletions(-)

gate history · 8 passed · 1 rejected · iteration 30

evidence per changed file
file                                               reads  edits  tests
docs/runtime/sql.mdx                                   6     11      8
packages/bun-types/sql.d.ts                           12      8      6
src/js/bun/sql.ts                                     12     18     12
src/js/internal/sql/postgres.ts                       32     58     38
src/js/internal/sql/shared.ts                          1      0      3
src/runtime/api/sql.classes.ts                         2      2      1
src/sql/postgres/protocol/NotificationResponse.rs      3      2      0
src/sql/shared/ConnectionFlags.rs                      2      2      0
src/sql_jsc/jsc.rs                                     2      4      1
src/sql_jsc/postgres/PostgresSQLConnection.rs         14     18      2
test/js/sql/postgres-listen-notify.test.ts            17     38      0

@robobun
robobun requested a review from alii as a code owner June 11, 2026 03:49
@robobun

robobun commented Jun 11, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 5:55 PM PT - Aug 13th, 2026

@robobun, your commit 915989f has 1 failures in Build #94932 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 32089

That installs a local version of the PR into your bun-32089 executable, so you can run:

bun-32089 --bun

@mintlify

mintlify Bot commented Jun 11, 2026

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
bun 🟢 Ready View Preview Jun 11, 2026, 3:51 AM

💡 Tip: Enable Workflows to automatically generate PRs for you.

@github-actions

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. sql: support postgresql notify/listen #18214 - Direct feature request for sql.listen / PostgreSQL LISTEN/NOTIFY support, which is exactly what this PR implements

If this is helpful, copy the block below into the PR description to auto-close this issue on merge.

Fixes #18214

🤖 Generated with Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. add sql listen, unlisten, notify for postgres #29710 - Same feature (sql.listen/unlisten/notify for PostgreSQL); this PR states it is rebased from add sql listen, unlisten, notify for postgres #29710
  2. feature(postgres): add listen/notify functionality #25511 - Earlier attempt at adding PostgreSQL LISTEN/NOTIFY functionality

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

This PR implements PostgreSQL LISTEN/NOTIFY support: it adds SQL.listen, SQL.unlisten, and SQL.notify types/docs, JS instance methods with validation and default delegations, a PostgresAdapter dedicated listen connection with reconnect/backoff and per-channel dispatch, JSC/Rust bindings to forward notifications and expose connection metadata, and comprehensive tests.

Changes

PostgreSQL LISTEN/NOTIFY pub/sub

Layer / File(s) Summary
Type definitions, JS entrypoints, and docs
packages/bun-types/sql.d.ts, src/js/bun/sql.ts, docs/runtime/sql.mdx
Adds TypeScript typings and docs for listen(channel, onnotify, onlisten?), unlisten(channel, onnotify?), and notify(channel, payload); wires JS-level sql.listen/unlisten/notify and default delegations with argument validation.
PostgresAdapter LISTEN/NOTIFY core
src/js/internal/sql/postgres.ts
Adds resolvePostgresPassword, dedicated listen connection state and creation (#createListenConnection, #ensureListenConnection), per-channel subscription/onlisten tracking, dispatching (#dispatchNotification), reconnect scheduling with jittered backoff and drop thresholds, LISTEN/UNLISTEN helpers, and public listen/unlisten implementations plus #closeListen ordering in close().
Runtime/JSC notification dispatch and metadata
src/runtime/api/sql.classes.ts, src/sql_jsc/jsc.rs, src/sql_jsc/postgres/PostgresSQLConnection.rs
Exposes onnotification getter/setter and processId/secretKey accessors; routes NotificationResponse into dispatch_notification which queues JS callbacks as microtasks; caches callback host functions; updates codegen to include onnotification.
LISTEN/NOTIFY test coverage
test/js/sql/sql.test.ts
Adds end-to-end tests for payload round-trip and ordering, onlisten timing and shared state, reconnect/resumption, multiple listeners, notify() payload rules, unlisten semantics and idempotency, input validation, in-flight/close races, and a mock wire-protocol server verifying BackendKeyData, LISTEN/UNLISTEN queries, and NotificationResponse handling.

Suggested reviewers

  • alii
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The PR fully implements the requirements from #18214: sql.listen for registering listeners per channel, sql.notify for sending notifications, sql.unlisten for removing listeners, matching postgres.js API behavior, and providing clear PostgreSQL-only errors for non-Postgres adapters.
Out of Scope Changes check ✅ Passed All changes are directly scoped to implementing LISTEN/NOTIFY support: TypeScript types, documentation, JS/Rust implementation, and comprehensive tests all directly address the feature requirements with no unrelated modifications.
Title check ✅ Passed The title clearly identifies the PostgreSQL LISTEN/NOTIFY API additions and accurately summarizes the main change.
Description check ✅ Passed The description includes both required sections and provides detailed implementation, API, and verification information.

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/js/internal/sql/postgres.ts (1)

1151-1157: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Validate timeout before tearing down LISTEN state.

close() calls #closeListen() before it range-checks options.timeout. Passing an invalid timeout throws, but by then the dedicated listen connection, channel maps, and reconnect timer have already been cleared, leaving the adapter open but silently unsubscribed.

Proposed fix
   async close(options?: { timeout?: number }): Promise<void> {
     if (this.closed) {
       return;
     }
-
-    this.#closeListen();
 
     let timeout = options?.timeout;
     if (timeout) {
       timeout = Number(timeout);
       if (timeout > 2 ** 31 || timeout < 0 || timeout !== timeout) {
         throw $ERR_INVALID_ARG_VALUE("options.timeout", timeout, "must be a non-negative integer less than 2^31");
       }
 
+      this.#closeListen();
       this.closed = true;
       if (timeout === 0 || !this.hasPendingQueries()) {
         await this.#close();
         return;
       }
@@
     } else {
+      this.#closeListen();
       this.closed = true;
       if (!this.hasPendingQueries()) {
         await this.#close();
         return;
       }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/js/internal/sql/postgres.ts` around lines 1151 - 1157, The close() method
currently calls `#closeListen`() before validating options.timeout, so an invalid
timeout throws after listen state, channel maps and reconnect timer are cleared;
move the timeout validation for options.timeout (including Number conversion and
the 0 <= timeout < 2**31 and NaN check that throws $ERR_INVALID_ARG_VALUE) to
occur before invoking `#closeListen`() so that invalid arguments are rejected
without tearing down the dedicated listen connection or clearing channel
maps/timers; ensure the validation logic references options.timeout and the same
$ERR_INVALID_ARG_VALUE error path used now.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/js/internal/sql/postgres.ts`:
- Around line 1525-1527: The current bug is that `#listenOnlistenCallbacks` stores
onlisten callbacks per channel in a Set, losing the mapping between an
individual onnotify subscription and its corresponding onlisten handler, so
unlisten(channel, onnotify) cannot remove the paired onlisten and it keeps
firing on reconnect via `#scheduleListenReconnect`(); fix by changing
`#listenOnlistenCallbacks` to store a Map<string, Map<onnotifyFn, onlistenFn>> (or
similar) so each channel maps onnotify→onlisten, update the code paths that add
entries (where listen registers both callbacks) to insert the onnotify→onlisten
pair, and update unlisten(channel, onnotify) to look up and remove the specific
onlisten callback for that onnotify (and clean up empty maps/entries) so
`#scheduleListenReconnect`() only re-registers remaining pairs.
- Around line 1801-1804: When creating the inFlight task that awaits
this.#ensureListenConnection() and then calls
this.#runListenQuery(...LISTEN...), add a guard after the await to skip calling
`#runListenQuery` if the channel was removed meanwhile by unlisten(). Concretely:
after the await this.#ensureListenConnection() and before
this.#runListenQuery(conn, `LISTEN ${this.#quoteChannel(channel)}`) check the
module's listener registry (the concrete structure used by listen()/unlisten(),
e.g. this.#listeners or whatever field tracks active channels) to see if the
channel is still registered; if it is not, simply return/resolve and do not send
the LISTEN. This ensures the early unlisten() prevents stale LISTEN being sent.

---

Outside diff comments:
In `@src/js/internal/sql/postgres.ts`:
- Around line 1151-1157: The close() method currently calls `#closeListen`()
before validating options.timeout, so an invalid timeout throws after listen
state, channel maps and reconnect timer are cleared; move the timeout validation
for options.timeout (including Number conversion and the 0 <= timeout < 2**31
and NaN check that throws $ERR_INVALID_ARG_VALUE) to occur before invoking
`#closeListen`() so that invalid arguments are rejected without tearing down the
dedicated listen connection or clearing channel maps/timers; ensure the
validation logic references options.timeout and the same $ERR_INVALID_ARG_VALUE
error path used now.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 7f3c7bfd-ece2-40ff-ba3a-3dce9a05cac2

📥 Commits

Reviewing files that changed from the base of the PR and between f8723b1 and 6efe30b.

📒 Files selected for processing (8)
  • docs/runtime/sql.mdx
  • packages/bun-types/sql.d.ts
  • src/js/bun/sql.ts
  • src/js/internal/sql/postgres.ts
  • src/runtime/api/sql.classes.ts
  • src/sql_jsc/jsc.rs
  • src/sql_jsc/postgres/PostgresSQLConnection.rs
  • test/js/sql/sql.test.ts

Comment thread src/js/internal/sql/postgres.ts Outdated
Comment thread src/js/internal/sql/postgres.ts Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/js/internal/sql/postgres.ts (1)

1527-1536: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Duplicate listen() calls with the same callback collapse into one subscription.

The registry keys subscriptions by onnotify function identity (Set/Map), and each returned handle later unsubscribes via unlisten(channel, onnotify). A second listen("ch", sameFn) therefore shares the same backing entry, so the first handle removes the second one too. This needs a per-listen token or refcounted subscription record rather than treating the callback as the subscription key. Based on the documented sub.unlisten() contract in docs/runtime/sql.mdx, each listen() result should remove only its own listener.

Also applies to: 1798-1805, 1857-1864

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/js/internal/sql/postgres.ts` around lines 1527 - 1536, The current
listen/unlisten logic collapses multiple listen(channel, onnotify) calls because
`#listenChannels` and `#listenOnlistenCallbacks` key by the onnotify function
identity; change this to track subscriptions per-listen call instead of by
callback identity: assign a unique token/id (or maintain a refcounted
subscription record) for each call in listen(), store records in `#listenChannels`
(e.g., Map<string, Map<id, {onnotify}>>) and in `#listenOnlistenCallbacks` (map by
channel → map by id → onlisten set), return an unlisten handle that removes only
that id record, and ensure existing LISTEN/UNLISTEN RPCs toggle actual DB LISTEN
only when the aggregate active-count for a channel goes from 0→1 or 1→0; update
listen(), unlisten(), and the code that iterates
`#listenChannels/`#listenOnlistenCallbacks to use the new id-based records.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/js/internal/sql/postgres.ts`:
- Around line 1162-1165: Set the adapter's closed flag before tearing down the
dedicated listen connection: move the assignment this.closed = true to occur
before calling this.#closeListen() (and apply the same change at the other
occurrence around the alternate close path). This ensures
`#createListenConnection`() will reject any late connects (preventing a late
resolution from repopulating this.#listenConnection or allowing listen() to
succeed after close()).
- Around line 1153-1156: The code treats timeout=0 as falsy because hasTimeout
is set via !!timeout; change the presence check to detect undefined instead
(e.g., const hasTimeout = options?.timeout !== undefined) and only call timeout
= Number(timeout) when hasTimeout is true so close({ timeout: 0 }) is honored
and converted to numeric 0; update the logic around the timeout and hasTimeout
variables in the same block (referencing timeout, hasTimeout, and
options?.timeout) accordingly.

---

Outside diff comments:
In `@src/js/internal/sql/postgres.ts`:
- Around line 1527-1536: The current listen/unlisten logic collapses multiple
listen(channel, onnotify) calls because `#listenChannels` and
`#listenOnlistenCallbacks` key by the onnotify function identity; change this to
track subscriptions per-listen call instead of by callback identity: assign a
unique token/id (or maintain a refcounted subscription record) for each call in
listen(), store records in `#listenChannels` (e.g., Map<string, Map<id,
{onnotify}>>) and in `#listenOnlistenCallbacks` (map by channel → map by id →
onlisten set), return an unlisten handle that removes only that id record, and
ensure existing LISTEN/UNLISTEN RPCs toggle actual DB LISTEN only when the
aggregate active-count for a channel goes from 0→1 or 1→0; update listen(),
unlisten(), and the code that iterates `#listenChannels/`#listenOnlistenCallbacks
to use the new id-based records.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 3bda564e-85be-4f54-bca1-41764c061140

📥 Commits

Reviewing files that changed from the base of the PR and between 6efe30b and 4b194cf.

📒 Files selected for processing (2)
  • src/js/internal/sql/postgres.ts
  • test/js/sql/sql.test.ts
👮 Files not reviewed due to content moderation or server errors (1)
  • test/js/sql/sql.test.ts

Comment thread src/js/internal/sql/postgres.ts Outdated
Comment thread src/js/internal/sql/postgres.ts Outdated
@robobun

robobun commented Jun 11, 2026

Copy link
Copy Markdown
Collaborator Author

Re the out-of-diff note about duplicate listen() calls with the same callback collapsing into one subscription: this is the intended semantics of this API rather than an accident of the bookkeeping.

The public surface is keyed by callback identity: sql.unlisten(channel, onnotify) removes "the listener identified by this function". Given that, a (channel, onnotify) pair is the subscription unit, registering the same pair twice is idempotent (one delivery per NOTIFY, not two), and the handle returned by listen() delegates to the same removal. "Remove just this listener" in the docs means the other listeners on the channel are unaffected, which holds.

Per-call token records would make the same function fire N times per NOTIFY when registered N times, and would leave unlisten(channel, fn) ambiguous (remove one? all?). postgres.js avoids this only by not having a function-keyed unlisten API at all. Happy to revisit if maintainers prefer exact postgres.js parity here.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/js/internal/sql/postgres.ts (1)

1153-1159: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Validate the timer's millisecond bound, not the seconds input.

timeout is multiplied by 1000 on Line 1179, so the current 2 ** 31 guard is off by three orders of magnitude. Large values can pass validation here and then overflow the actual timer range, which makes the forced-close path run almost immediately instead of waiting for the requested timeout.

Proposed fix
     let timeout = options?.timeout;
     const hasTimeout = !!timeout;
+    let timeoutMs = 0;
     if (hasTimeout) {
       timeout = Number(timeout);
-      if (timeout > 2 ** 31 || timeout < 0 || timeout !== timeout) {
+      timeoutMs = timeout * 1000;
+      if (timeoutMs > 2 ** 31 - 1 || timeout < 0 || timeout !== timeout) {
         throw $ERR_INVALID_ARG_VALUE("options.timeout", timeout, "must be a non-negative integer less than 2^31");
       }
     }
@@
-      }, timeout * 1000);
+      }, timeoutMs);

Also applies to: 1175-1179

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/js/internal/sql/postgres.ts` around lines 1153 - 1159, The validation
currently checks the seconds value against 2**31 but the code multiplies timeout
by 1000 later, so convert and validate in milliseconds: coerce options.timeout
to Number into the existing timeout variable, ensure it's a non-negative integer
(timeout === Math.floor(timeout) and not NaN), compute const timeoutMs = timeout
* 1000, and validate timeoutMs is <= 2**31 - 1 (and >= 0) before using it (or
replace later multiplication to use timeoutMs). Update references that rely on
the multiplied value to use the validated timeoutMs so the timer bound matches
Node's millisecond limit.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/js/internal/sql/postgres.ts`:
- Around line 1153-1159: The validation currently checks the seconds value
against 2**31 but the code multiplies timeout by 1000 later, so convert and
validate in milliseconds: coerce options.timeout to Number into the existing
timeout variable, ensure it's a non-negative integer (timeout ===
Math.floor(timeout) and not NaN), compute const timeoutMs = timeout * 1000, and
validate timeoutMs is <= 2**31 - 1 (and >= 0) before using it (or replace later
multiplication to use timeoutMs). Update references that rely on the multiplied
value to use the validated timeoutMs so the timer bound matches Node's
millisecond limit.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 6f44ec4f-664e-4450-837f-88f6ebab9303

📥 Commits

Reviewing files that changed from the base of the PR and between 4b194cf and bdad3a2.

📒 Files selected for processing (1)
  • src/js/internal/sql/postgres.ts

@robobun

robobun commented Jun 11, 2026

Copy link
Copy Markdown
Collaborator Author

Re the out-of-diff note on the timeout bound (2^31 checked in seconds, timer armed in milliseconds): that guard is pre-existing main code shared by both adapters, not introduced here, so I am not changing it in this PR. Tracked in #32091 together with the timeout: 0 handling.

@robobun

robobun commented Jun 11, 2026

Copy link
Copy Markdown
Collaborator Author

CI status: the diff is green everywhere it runs. Two consecutive builds failed only on the darwin 14 aarch64 - test-bun lane, each on runner infrastructure unrelated to this PR:

  • Build 61871: vendor build failed with Cannot find package 'tsup' in vendor/elysia, then the VM was cancelled (timed out). 285 other jobs passed, including every Linux docker lane running the PostgreSQL LISTEN/NOTIFY suite.
  • Build 61890 (retrigger): the same lane stalled on Valkey tests with RedisError: Max reconnection attempts reached (ERR_REDIS_CONNECTION_CLOSED), each test burning ~31s in reconnects until the job hit its time limit. Again 285 jobs passed; sql.test.ts ran in a sibling shard of the same platform and passed.

The Windows bun-install/update_interactive failures in 61871 passed on retry. Nothing in either failure touches this PR's code paths (SQL, postgres, or the new tests). Ready for review.

@alii

alii commented Jun 11, 2026

Copy link
Copy Markdown
Member

This should be an async resource and work with "using" @robobun

@robobun

robobun commented Jun 11, 2026

Copy link
Copy Markdown
Collaborator Author

Done in d7074dc: the subscription returned by listen() now implements Symbol.asyncDispose (delegating to its unlisten()), so it works with await using:

{
  await using sub = await sql.listen("user_events", payload => console.log(payload));
  // ...
} // scope exit unlistens

Types, docs, and tests updated (a mock-server test asserts UNLISTEN is sent on scope exit, and a docker-suite test asserts delivery stops).

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
src/js/internal/sql/postgres.ts (3)

1800-1808: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Same onnotify function cannot back two independent listen() handles.

The registry is keyed only by onnotify, so two listen(channel, sameFn, ...) calls collapse into one entry. When either returned handle calls unlisten(), Lines 1886-1891 remove that shared entry and tear down the other live subscription too. That breaks the per-call cleanup contract implied by the returned unlisten/[Symbol.asyncDispose] handle.

This needs per-subscription bookkeeping or a refcount that is separate from the callback identity.

Also applies to: 1859-1868, 1886-1891

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/js/internal/sql/postgres.ts` around lines 1800 - 1808, The current
listen() bookkeeping keys registrations solely by the onnotify function, so
multiple listen(channel, sameFn, ...) calls share the same entry and unlisten()
from one handle removes the other; change the registry to track subscriptions
per-listen call (either by using unique subscription IDs or a per-subscription
wrapper object) instead of using onnotify as the sole key. Update
`#listenChannels` and `#listenOnlistenCallbacks` to map channel ->
Map<subscriptionId, {onnotify, onlistenSet}> (or maintain a refcount for
identical handlers) so that each listen() returns an
unlisten/Symbol.asyncDispose that only removes its subscriptionId, and adjust
the unlisten() logic (and the logic at the spots you noted around lines
1859-1868 and 1886-1891) to only teardown the actual channel listener when no
subscriptionIds remain.

1627-1627: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Close the dedicated LISTEN socket when the last subscription is removed.

Line 1627 ref()s the dedicated listen connection, but Lines 1903-1908 only clear the reconnect timer. After the final unlisten(), the adapter still holds an idle backend session open and can keep the process alive until close() is called.

Also applies to: 1903-1908

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/js/internal/sql/postgres.ts` at line 1627, The dedicated LISTEN
connection (conn) is being ref()'d but never closed when the last subscription
is removed; update the unlisten() / subscription-removal path so that when the
final subscription is dropped you both clear the reconnect timer (existing lines
~1903-1908) and explicitly tear down the dedicated listen connection: call the
appropriate close/release on conn (conn.end() or conn.release() depending on how
conn was acquired) and undo the ref by calling conn.unref() (or the equivalent)
so the idle backend session is closed and the process can exit; make the same
change in the reconnect-timer-clear branch so that clearing the timer also
closes/unrefs conn when there are no subscriptions left.

1153-1159: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Clamp close({ timeout }) in milliseconds, not seconds.

The validation accepts values up to 2 ** 31, but Line 1179 multiplies timeout by 1000 before handing it to setTimeout(). Any value above about 2_147_483 seconds passes validation and then overflows/clamps the actual timer delay, so a long graceful close can fire much earlier than requested.

Suggested fix
     let timeout = options?.timeout;
     const hasTimeout = !!timeout;
     if (hasTimeout) {
       timeout = Number(timeout);
-      if (timeout > 2 ** 31 || timeout < 0 || timeout !== timeout) {
+      const timeoutMs = timeout * 1000;
+      if (
+        timeout < 0 ||
+        timeout !== timeout ||
+        timeoutMs > 2 ** 31 - 1
+      ) {
         throw $ERR_INVALID_ARG_VALUE("options.timeout", timeout, "must be a non-negative integer less than 2^31");
       }
     }
@@
-      const timer = setTimeout(() => {
+      const timer = setTimeout(() => {
         // timeout is reached, lets close and probably fail some queries
         this.#close().finally(resolve);
-      }, timeout * 1000);
+      }, timeout * 1000);

Also applies to: 1175-1179

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/js/internal/sql/postgres.ts` around lines 1153 - 1159, The timeout
validation currently treats the value as seconds but later multiplies by 1000
before setTimeout, causing overflow; update the logic so timeout is
clamped/validated as milliseconds (a non-negative integer <= 2**31 - 1) and
ensure the value passed to setTimeout is that validated millisecond value
(adjust/remove the later *1000). Target the code handling options.timeout
(variables timeout and hasTimeout) and the close/setTimeout usage so the same
validated timeout (in ms) is used end-to-end.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/js/internal/sql/postgres.ts`:
- Around line 1800-1808: The current listen() bookkeeping keys registrations
solely by the onnotify function, so multiple listen(channel, sameFn, ...) calls
share the same entry and unlisten() from one handle removes the other; change
the registry to track subscriptions per-listen call (either by using unique
subscription IDs or a per-subscription wrapper object) instead of using onnotify
as the sole key. Update `#listenChannels` and `#listenOnlistenCallbacks` to map
channel -> Map<subscriptionId, {onnotify, onlistenSet}> (or maintain a refcount
for identical handlers) so that each listen() returns an
unlisten/Symbol.asyncDispose that only removes its subscriptionId, and adjust
the unlisten() logic (and the logic at the spots you noted around lines
1859-1868 and 1886-1891) to only teardown the actual channel listener when no
subscriptionIds remain.
- Line 1627: The dedicated LISTEN connection (conn) is being ref()'d but never
closed when the last subscription is removed; update the unlisten() /
subscription-removal path so that when the final subscription is dropped you
both clear the reconnect timer (existing lines ~1903-1908) and explicitly tear
down the dedicated listen connection: call the appropriate close/release on conn
(conn.end() or conn.release() depending on how conn was acquired) and undo the
ref by calling conn.unref() (or the equivalent) so the idle backend session is
closed and the process can exit; make the same change in the
reconnect-timer-clear branch so that clearing the timer also closes/unrefs conn
when there are no subscriptions left.
- Around line 1153-1159: The timeout validation currently treats the value as
seconds but later multiplies by 1000 before setTimeout, causing overflow; update
the logic so timeout is clamped/validated as milliseconds (a non-negative
integer <= 2**31 - 1) and ensure the value passed to setTimeout is that
validated millisecond value (adjust/remove the later *1000). Target the code
handling options.timeout (variables timeout and hasTimeout) and the
close/setTimeout usage so the same validated timeout (in ms) is used end-to-end.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 92d37c30-faa4-4ad4-9001-b393b001e9c7

📥 Commits

Reviewing files that changed from the base of the PR and between a45493c and d7074dc.

📒 Files selected for processing (4)
  • docs/runtime/sql.mdx
  • packages/bun-types/sql.d.ts
  • src/js/internal/sql/postgres.ts
  • test/js/sql/sql.test.ts
👮 Files not reviewed due to content moderation or server errors (1)
  • test/js/sql/sql.test.ts

@robobun

robobun commented Jun 11, 2026

Copy link
Copy Markdown
Collaborator Author

Re the three out-of-diff findings from the latest review:

  1. Dedicated connection left open after the last unlisten: fixed in b116124. unlisten() now closes the dedicated connection when the last subscription is removed (closing implicitly drops every server-side LISTEN registration, so the final UNLISTEN round-trip is skipped), and the in-flight listen guard closes a freshly-created connection whose channel was unlistened during setup. The next listen() recreates it. Mock-server tests assert the close and the docker suite still covers reconnect behavior.

    While verifying this I found that an active subscription does not actually keep the process alive (the conn.ref() is cancelled by unrelated event-loop accounting, so a subscribed-only process exits instead of waiting for NOTIFY). That is a pre-existing native keepalive issue shared by all SQL connections, filed as sql.listen() subscription does not keep the process alive while waiting for notifications #32127 with a trace rather than widening this PR into the uws loop bookkeeping.

  2. Duplicate listen(channel, sameFn) collapsing into one subscription: declined, same rationale as before (see the earlier discussion in this thread). The public API is callback-keyed (unlisten(channel, onnotify)), so a (channel, onnotify) pair is the subscription unit and re-registering it is idempotent. Per-call tokens would make the same function fire N times per NOTIFY and leave the public unlisten(channel, fn) ambiguous.

  3. close({ timeout }) validating seconds against the millisecond bound: declined as out of scope. Pre-existing main behavior shared by both adapters, tracked in sql.close({ timeout: 0 }) is treated as no timeout instead of closing immediately #32091 together with the timeout: 0 handling.

@robobun

robobun commented Jun 11, 2026

Copy link
Copy Markdown
Collaborator Author

CI status update for build 61927 (sha b116124): three failure signals, none attributable to this PR.

  • test/js/third_party/@duckdb/node-api/duckdb.test.ts segfaulted on one debian 13 x64 shard (release build, NAPI addon crash at 0x1E128000). The commits since build 61890, where every debian 13 x64 shard including this test passed, are d7074dc and b116124, which touch only src/js/internal/sql/postgres.ts, packages/bun-types/sql.d.ts, docs/runtime/sql.mdx, and test/js/sql/sql.test.ts (verified with git log --stat d0068d745c..b116124e0f). The native/codegen side is byte-identical to those green builds, and the postgres adapter module is never loaded by the duckdb test. Four local runs under the ASAN debug build produced no crash (one run hit the test's 5s timeout at 5.9s, which is debug/ASAN slowness).
  • test/js/bun/http/serve-body-leak.test.ts on 13 x64-asan: memory-threshold assertion (543 MB vs 512 MB limit), passed on retry, unrelated subsystem.
  • test/cli/install/bun-install.test.ts on Windows 2019 x64-baseline: same install flake as the two previous builds, passed on retry.

The retrigger budget for this branch is spent (one was used after build 61871's macOS runner infra failures), so I am leaving CI as is rather than pushing empty commits. Every lane that exercises this PR's code paths is green.

@robobun

robobun commented Jun 11, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed 36adfc0, which fixes #32127 (sql.listen() subscription does not keep the process alive).

Root cause: the adapter calls conn.ref() on the dedicated listen connection, but the request was not sticky. The tail of on_data in PostgresSQLConnection.rs unconditionally drops the poll ref whenever the connection is idle (connected, no query running, write buffer empty), so the loop's active count hit 0 right after the LISTEN round-trip completed and the process exited. That accounting predates this PR, but listen() is the first consumer that needs an idle-yet-referenced connection, so the fix lands here where it is observable and testable.

The fix tracks the explicit JS ref()/unref() in a new KEEP_ALIVE_REQUESTED connection flag and has the idle unref path honor it. Close and failure paths still release the poll ref unconditionally, so "last unlisten closes the connection and frees the process" keeps working (covered by the existing exit test).

New mock-backed test: the notification is sent only after the child prints SUBSCRIBED, so the child can only receive it if the subscription held the process open. On the unfixed build the child exits first and the test fails.

Note: MySQLConnection's ref() has the same non-sticky behavior, left unchanged here since nothing calls it (LISTEN/NOTIFY is Postgres-only) and there is no observable behavior to test.

@robobun

robobun commented Jun 11, 2026

Copy link
Copy Markdown
Collaborator Author

CI status for build 61931 (sha 36adfc0): one hard failure, the same @duckdb/node-api segfault as the previous build, now investigated in depth and filed as #32134.

Evidence it is not caused by this PR:

  • The crashing process never loads any sql code (crash banner lists its builtins; the postgres adapter is not among them), and the failing shard did not run test/js/sql/sql.test.ts at all.
  • The commits between the last duckdb-green build (61890) and the first crash (61927) touch only the JS adapter, types, docs, and tests; the native side was byte-identical.
  • 20 consecutive local runs of the duckdb test with a release-profile build at this exact commit (Linux x64) pass cleanly in ~350ms each. CI crashes ~1.5s in, after ~5 tests, with page-aligned fault addresses that differ between runs, consistent with a load-sensitive latent bug in the NAPI addon path rather than anything this PR changes.

The other annotated failure (next-pages dev-server-ssr-100 on Windows, ConnectionRefused at request 99) passed on retry. 282 of 284 completed jobs are green, all review threads are resolved, and the retrigger budget for this branch was already spent, so I am not pushing further empty commits. Ready for review; #32134 tracks the duckdb flake.

Comment thread src/js/internal/sql/postgres.ts
Comment thread src/js/internal/sql/postgres.ts Outdated
Comment thread test/js/sql/sql.test.ts Outdated
Comment thread test/js/sql/postgres-listen-notify.test.ts
Comment thread src/js/internal/sql/postgres.ts Outdated
Comment thread src/js/internal/sql/postgres.ts
Comment thread src/js/internal/sql/postgres.ts Outdated
@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

Triage note: the two other open LISTEN/NOTIFY implementations, #25511 (structwafel) and #29710 (versecafe), are now closed in favor of this PR. Both modify the Zig files removed by the Rust port and no longer apply to main.

Nothing from either remained to be folded in: #29710 is the branch this PR grew out of (its design is the basis here and versecafe is co-author on the commits), and the parts of #25511 not already covered (listen() resolving to a bare unlisten function, sql.unlisten(channel), a reserved pool connection instead of a dedicated one) are the shape the review above moved away from. The one open item on this PR is still cirospaciari's question about whether the methods should be visibly scoped as Postgres-only, which is with @alii.

… sent

With the listen connection already live, a close() in the same tick as a
listen() cleared the channel table while #subscribe was still on its first
await; #subscribe then returned without sending anything and listen()
resolved with an inert subscription. Since a caller's own registration is
the only thing that keeps an entry in the table, an entry that is gone after
the round trip means the client was closed, so listen() now rejects with the
connection-closed error like the other close-during-listen paths.
@cirospaciari

Copy link
Copy Markdown
Member

@robobun check tests from postgres.js github so we port then

@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

Looking at postgres.js's listen/notify tests now; will port the ones that apply and list what each maps to.

The ten listen/notify tests from postgres.js's tests/index.js, adapted to
the subscription object and with their delay() calls replaced by waiting
for the deliveries themselves. They replace the two docker tests they
subsumed (unlisten stopping delivery, reconnecting after the listening
backend is terminated); the payload and transaction tests stay. Verified
against a local PostgreSQL 17 as well.
@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

@cirospaciari Ported in db9546a. postgres.js has eleven listen/notify tests in tests/index.js (lines 781 to 950 at 23e6c4d); the relevant ten are now in this file's docker suite under describe("ported from postgres.js"), keeping their names:

postgres.js test here
listen and notify as is
double listen as is (second listen() on the same channel also receives)
multiple listeners work after a reconnect as is; 1a2a1b2b
listen and notify with weird name (wat-;.ø.§) as is, plus nothing arrives after unlisten()
listen and notify with upper case (withUpperChar) as is (exercises the identifier quoting)
listen reconnects as is; onlisten runs twice across the terminate
listen result reports correct connection state after reconnection adapted: there is no state.pid any more, so it checks pg_stat_activity shows a different backend holding the LISTEN afterwards
unlisten removes subscription as is; a
listen after unlisten as is; ac
multiple listeners and unlisten one as is; 1a2a1b
responds with server parameters (application_name) not about listen/notify, skipped

Two adaptations apply throughout: { unlisten } / s2.unlisten() became the subscription object, and every delay(50) became waiting for the delivery itself (or for a barrier notification, since one connection receives notifications in order). pg_terminate_backend(state.pid) became terminating the backend whose current query is our LISTEN. They replace the two docker tests of mine they subsumed; the payload and transaction ones stay. All twelve docker tests also pass three times in a row against a local PostgreSQL 17.

cirospaciari
cirospaciari previously approved these changes Aug 13, 2026
@cirospaciari

Copy link
Copy Markdown
Member

@alii if you think the DX is ok (look ok for me) feel free to approve and merge it

@cirospaciari

Copy link
Copy Markdown
Member

@robobun fix conflicts

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

@cirospaciari Conflicts fixed in 06ec075 (merged main). The only conflict was file() in sql.d.ts, where main widened values to any[] | Record<string, any>; kept main's signature with listen()/notify() declared after it as before. On the merged tree the 44 mock and subprocess tests pass, the 12 docker tests pass against a local PostgreSQL 17, and the bun-types check passes (15/15).

Comment thread src/js/internal/sql/postgres.ts Outdated
@cirospaciari
cirospaciari merged commit 2f5c180 into main Aug 14, 2026
41 of 42 checks passed
@cirospaciari
cirospaciari deleted the farm/fce8bc84/pg-listen-notify branch August 14, 2026 17:23
dylan-conway added a commit that referenced this pull request Aug 14, 2026
…38628)

### What does this PR do?

`main` does not compile: #32089 (sql.listen/notify) calls
`JSGlobalObject::report_active_exception_as_unhandled`, which #37275
removed (`bun_sql_jsc`: "no method named
`report_active_exception_as_unhandled`", ×2 in
`PostgresSQLConnection.rs`).

A NOTIFY channel/payload string the VM could not make (OOM, a worker's
termination) is now handled the way every other JS conversion failure
inside `PostgresSQLConnection::on()` is: `on_notification` returns
`AnyPostgresError::JSError`, and the connection's error path takes the
pending exception and fails the connection with it — which is also
#37275's rule that an `Err` always leaves its exception pending for
exactly one taker.

### How did you verify your code works?

`cargo check -p bun_sql_jsc` and `cargo check -p bun_bin` (full crate
graph) pass. The LISTEN/NOTIFY tests need a Postgres server (CI's docker
lane).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

sql.listen() subscription does not keep the process alive while waiting for notifications sql: support postgresql notify/listen

5 participants