-
Couldn't load subscription status.
- Fork 108
Description
- 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 ?