Skip to content

unsubscribe(callback) on a shared live query removes all subscribers and tears down the query #1048

Description

@viswa-abe

Passing a specific callback to unsubscribe() on a live query removes every subscriber, not just that callback, and destroys the query's backing state. With two callbacks subscribed to one live.query, calling unsubscribe(cbA) and then committing a mutation delivers nothing to cbB; a second mutation delivers nothing either, so the query is fully torn down rather than left running for the remaining subscriber.

Environment:

  • @electric-sql/pglite 0.5.4 (latest on npm), also present on current main
  • Node v22.23.1, Linux

Reproduction:

npm i @electric-sql/pglite@0.5.4
node repro.mjs
repro.mjs
import { PGlite } from '@electric-sql/pglite'
import { live } from '@electric-sql/pglite/live'

const db = await PGlite.create({ extensions: { live } })
await db.exec(`CREATE TABLE t (id int primary key, v text);`)

let aCalls = 0, bCalls = 0
const cbA = () => { aCalls++ }
const cbB = () => { bCalls++ }

const sub = await db.live.query('SELECT * FROM t ORDER BY id', [], cbA)
sub.subscribe(cbB)
await new Promise((r) => setTimeout(r, 100))

// remove only cbA; cbB is still a subscriber
await sub.unsubscribe(cbA)
const bBefore = bCalls

await db.exec(`INSERT INTO t VALUES (1, 'a');`)
await new Promise((r) => setTimeout(r, 100))
console.log(`cbB fired ${bCalls - bBefore} time(s) after the INSERT; expected 1`)

await db.close()

Observed output:

cbB fired 0 time(s) after the INSERT; expected 1

Expected: cbB should keep receiving updates. The live-query docs describe multiple consumers sharing one live query, and the API exposes unsubscribe(callback) specifically to remove one callback while leaving the others subscribed. https://pglite.dev/docs/live-queries

This appears to come from the selective-unsubscribe filter in packages/pglite/src/live/index.ts (lines 243, 506, 666), which reads callbacks = callbacks.filter((callback) => callback !== callback). The arrow's parameter shadows the outer callback, so the predicate is always false, the filtered array is empty, and the length === 0 branch then drops the view and deallocates the query's state. Binding the predicate to the outer callback ((cb) => cb !== callback) would remove only the named one.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions