diff --git a/.changeset/fix-live-query-default-getkey.md b/.changeset/fix-live-query-default-getkey.md new file mode 100644 index 000000000..6ff4c2536 --- /dev/null +++ b/.changeset/fix-live-query-default-getkey.md @@ -0,0 +1,7 @@ +--- +'@tanstack/db': patch +--- + +fix: default getKey on live query collections fails when used as a source in chained collections + +The default WeakMap-based getKey breaks when enriched change values (with virtual props like $synced, $origin, $key) are passed through chained live query collections. The enriched objects are new references not found in the WeakMap, causing all items to resolve to key `undefined` and collapse into a single item. Falls back to `item.$key` when the WeakMap lookup misses. diff --git a/packages/db/src/query/live/collection-config-builder.ts b/packages/db/src/query/live/collection-config-builder.ts index 20fb26f79..3a4e948e1 100644 --- a/packages/db/src/query/live/collection-config-builder.ts +++ b/packages/db/src/query/live/collection-config-builder.ts @@ -227,7 +227,8 @@ export class CollectionConfigBuilder< id: this.id, getKey: this.config.getKey || - ((item) => this.resultKeys.get(item) as string | number), + ((item: any) => + (this.resultKeys.get(item) ?? item.$key) as string | number), sync: this.getSyncConfig(), compare: this.compare, defaultStringCollation: this.compareOptions, diff --git a/packages/db/tests/query/live-query-collection.test.ts b/packages/db/tests/query/live-query-collection.test.ts index d56e5a715..7179cd388 100644 --- a/packages/db/tests/query/live-query-collection.test.ts +++ b/packages/db/tests/query/live-query-collection.test.ts @@ -2585,4 +2585,121 @@ describe(`createLiveQueryCollection`, () => { await preloadPromise }) }) + + describe(`chained live query collections without custom getKey`, () => { + it(`should return all items when a live query collection without getKey is used as a source`, async () => { + // Create a live query collection with the default (internal) getKey + const filteredUsers = createLiveQueryCollection({ + id: `filtered-users`, + query: (q) => + q + .from({ user: usersCollection }) + .where(({ user }) => eq(user.active, true)) + .select(({ user }) => ({ + id: user.id, + name: user.name, + })), + }) + + // Use the live query collection as a source in another live query collection + const derived = createLiveQueryCollection({ + id: `derived-from-live-query`, + query: (q) => q.from({ u: filteredUsers }), + }) + + await derived.preload() + + // Should contain all active users (Alice and Bob), not just 1 + expect(derived.size).toBe(2) + }) + + it(`should return all items when a live query collection with a join and no getKey is used as a source`, async () => { + type Team = { + id: number + name: string + lead_id: number + } + + const teamsCollection = createCollection( + mockSyncCollectionOptions({ + id: `test-teams`, + getKey: (team) => team.id, + initialData: [ + { id: 1, name: `Alpha`, lead_id: 1 }, + { id: 2, name: `Beta`, lead_id: 2 }, + { id: 3, name: `Gamma`, lead_id: 1 }, + ], + }), + ) + + // Join teams with users — no custom getKey + const teamsWithLeads = createLiveQueryCollection({ + id: `teams-with-leads`, + query: (q) => + q + .from({ team: teamsCollection }) + .join({ user: usersCollection }, ({ team, user }) => + eq(team.lead_id, user.id), + ) + .select(({ team, user }) => ({ + teamName: team.name, + leadName: user.name, + })), + }) + + // Use the joined live query collection as a source + const derived = createLiveQueryCollection({ + id: `derived-from-join`, + query: (q) => q.from({ t: teamsWithLeads }), + }) + + await derived.preload() + + // Should contain all 3 joined rows, not just 1 + expect(derived.size).toBe(3) + expect(derived.toArray.map((row) => stripVirtualProps(row))).toEqual( + expect.arrayContaining([ + expect.objectContaining({ teamName: `Alpha`, leadName: `Alice` }), + expect.objectContaining({ teamName: `Beta`, leadName: `Bob` }), + expect.objectContaining({ teamName: `Gamma`, leadName: `Alice` }), + ]), + ) + }) + + it(`should propagate updates through chained live query collections without custom getKey`, async () => { + // Intermediate live query collection — no custom getKey + const intermediate = createLiveQueryCollection({ + id: `update-intermediate`, + query: (q) => + q.from({ user: usersCollection }).select(({ user }) => ({ + id: user.id, + name: user.name, + })), + }) + + // Derived from the intermediate + const derived = createLiveQueryCollection({ + id: `update-derived`, + query: (q) => q.from({ u: intermediate }), + }) + + await derived.preload() + + // Should have all 3 users from sampleUsers, not just 1 + expect(derived.size).toBe(3) + + // Sync a new user into the source collection + usersCollection.utils.begin() + usersCollection.utils.write({ + type: `insert`, + value: { id: 4, name: `Diana`, active: true }, + }) + usersCollection.utils.commit() + + await flushPromises() + + // The derived collection should see all 4 items + expect(derived.size).toBe(4) + }) + }) })