Skip to content

live query with joins emits delete events on ready #677

@sunghwan2789

Description

@sunghwan2789
  • I've validated the bug against the latest version of DB packages

Describe the bug

When creating a live query with a three-collection chained LEFT JOIN (A -> B -> C), an issue occurs if the middle collection (B) is initially empty and is populated after the initial sync. The query subscription incorrectly emits "delete" and "insert" events, instead of "update" events, even though the data in the result set is only being updated, not removed or added.

A full reproduction is available in this test case:
https://github.com/sunghwan2789/db/blob/9e1f1d0bfad4e81634671671300467695e01eedc/packages/db/tests/query/join.test.ts#L1251-L1313

Test Setup:

// Test schema for three-collection join subscription
type Player = {
  name: string
  club_id: string
  position: string
}

type Client = {
  name: string
  player: string // references Player.name
  email: string
}

type Balance = {
  name: string
  client: string // references Client.name
  amount: number
}

// Sample data
const samplePlayers: Array<Player> = [
  { name: `player1`, club_id: `club1`, position: `forward` },
  { name: `player2`, club_id: `club1`, position: `midfielder` },
  { name: `player3`, club_id: `club1`, position: `defender` },
]

const sampleClients: Array<Client> = [
  { name: `client1`, player: `player1`, email: `client1@example.com` },
  { name: `client2`, player: `player2`, email: `client2@example.com` },
  { name: `client3`, player: `player3`, email: `client3@example.com` },
]

const sampleBalances: Array<Balance> = [
  { name: `balance1`, client: `client1`, amount: 1000 },
  { name: `balance2`, client: `client2`, amount: 2000 },
  { name: `balance3`, client: `client3`, amount: 1500 },
]

const playersCollection = createCollection(
  mockSyncCollectionOptions<Player>({
    id: `test-players-subscription-${autoIndex}`,
    getKey: (player) => player.name,
    initialData: samplePlayers,
    autoIndex,
  })
)

const clientsCollection = createCollection(
  mockSyncCollectionOptionsNoInitialState<Client>({
    id: `test-clients-subscription-${autoIndex}`,
    getKey: (client) => client.name,
    autoIndex,
  })
)

const balancesCollection = createCollection(
  mockSyncCollectionOptions<Balance>({
    id: `test-balances-subscription-${autoIndex}`,
    getKey: (balance) => balance.name,
    initialData: sampleBalances,
    autoIndex,
  })
)

// Create chained join query with three collections using left joins
const chainedJoinQuery = createLiveQueryCollection({
  startSync: true,
  query: (q) =>
    q
      .from({ player: playersCollection })
      .join(
        { client: clientsCollection },
        ({ client, player }) => eq(client.player, player.name),
        `left`
      )
      .join(
        { balance: balancesCollection },
        ({ balance, client }) => eq(balance.client, client?.name),
        `left`
      )
      .select(({ player, client, balance }) => ({
        player_name: player.name,
        client_name: client?.name,
        balance_amount: balance?.amount,
      })),
})

// Track all change events
const changeEvents: Array<any> = []
const subscription = chainedJoinQuery.subscribeChanges((changes) => {
  changeEvents.push(...changes)
})

// Initial state should have 3 results (left join includes all players even without clients)
expect(chainedJoinQuery.toArray).toHaveLength(3)
// Verify players are present but clients are null
const initialResults = chainedJoinQuery.toArray
expect(initialResults.every((r) => r.client_name === undefined)).toBe(
  true
)
expect(initialResults.every((r) => r.balance_amount === undefined)).toBe(
  true
)

// Clear any initial events from subscription setup
changeEvents.length = 0

// Insert client data after eager sync completion and first assertion
clientsCollection.utils.begin()
sampleClients.forEach((client) => {
  clientsCollection.utils.write({ type: `insert`, value: client })
})
clientsCollection.utils.commit()
clientsCollection.utils.markReady()

// Should still have 3 results, but now with client data
expect(chainedJoinQuery.toArray).toHaveLength(3)
// Verify clients are now populated
const resultsAfterInsert = chainedJoinQuery.toArray
expect(resultsAfterInsert.every((r) => r.client_name !== undefined)).toBe(
  true
)
// Verify change events are emitted
expect({
  delete: changeEvents.filter((c) => c.type === `delete`).length,
  update: changeEvents.filter((c) => c.type === `update`).length,
  insert: changeEvents.filter((c) => c.type === `insert`).length,
}).toEqual({ delete: 0, update: 3, insert: 0 })
// fail with actual: { delete 3, update:0, insert: 6 }

Expected Behavior

When the clientsCollection is populated, the existing items in the chainedJoinQuery result set should be updated. Since the change events are keyed to the player, we expect to receive 3 update events.

Actual Behavior

The subscription incorrectly reports 3 delete events and 6 insert events, instead of 3 update events.

Additional Context

When attempting to provide a custom getKey option to the createLiveQueryCollection to work around this, the query fails with a DuplicateKeySyncError.

diff --git a/packages/db/tests/query/join.test.ts b/packages/db/tests/query/join.test.ts
index a1812de..5a0004c 100644
--- a/packages/db/tests/query/join.test.ts
+++ b/packages/db/tests/query/join.test.ts
@@ -1250,6 +1250,7 @@ function createJoinTests(autoIndex: `off` | `eager`): void {
       // Create chained join query with three collections using left joins
       const chainedJoinQuery = createLiveQueryCollection({
         startSync: true,
+        getKey: (r) => r.player_name,
         query: (q) =>
           q
             .from({ player: playersCollection })
Uncaught CollectionOperationError: Cannot insert document with key "player1" from sync because it already exists in the collection "live-query-56"

I think this is a regression of #658 ?

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