Skip to content

Commit a90f95e

Browse files
KyleAMathewsclaude
andauthored
Auto append to static queryKey when in on-demand mode for Query Collection (#800)
* feat: automatically append predicates to static queryKey in on-demand mode When using a static queryKey with syncMode: 'on-demand', the system now automatically appends serialized LoadSubsetOptions to create unique cache keys for different predicate combinations. This fixes an issue where static queryKeys in on-demand mode would cause all live queries with different predicates to share the same cache entry, defeating the purpose of predicate push-down. Changes: - Added serialization utilities for LoadSubsetOptions (serializeLoadSubsetOptions, serializeExpression, serializeValue) - Modified createQueryFromOpts to automatically append serialized predicates when queryKey is static and syncMode is 'on-demand' - Function-based queryKeys continue to work as before - Eager mode with static queryKeys unchanged (no automatic serialization) Tests: - Added comprehensive test suite for static queryKey with on-demand mode - Tests verify different predicates create separate cache entries - Tests verify identical predicates reuse the same cache entry - Tests verify eager mode behavior unchanged - All existing tests pass * chore: add changeset for static queryKey on-demand mode fix * chore: update changeset to patch instead of minor * chore: format changeset with prettier * refactor: address PR review feedback Addresses Kevin's review comments: 1. Move serialization functions to dedicated utility file - Created src/serialization.ts with serializeLoadSubsetOptions, serializeExpression, and serializeValue functions - Keeps query.ts focused on query logic 2. Fix return type and use undefined instead of null - Changed serializeLoadSubsetOptions return type from `unknown` to `string | undefined` - Returns undefined instead of null for empty options - Updated usage to conditionally append only when serialized result is not undefined 3. Add missing CompareOptions properties to orderBy serialization - Now includes stringSort, locale, and localeOptions properties - Properly handles the StringCollationConfig union type with conditional serialization for locale-specific options All runtime tests pass (65/65 in query.test.ts). --------- Co-authored-by: Claude <[email protected]>
1 parent e95874b commit a90f95e

File tree

4 files changed

+356
-1
lines changed

4 files changed

+356
-1
lines changed
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
"@tanstack/query-db-collection": patch
3+
---
4+
5+
Automatically append predicates to static queryKey in on-demand mode.
6+
7+
When using a static `queryKey` with `syncMode: 'on-demand'`, the system now automatically appends serialized LoadSubsetOptions to create unique cache keys for different predicate combinations. This fixes an issue where all live queries with different predicates would share the same TanStack Query cache entry, causing data to be overwritten.
8+
9+
**Before:**
10+
11+
```typescript
12+
// This would cause conflicts between different queries
13+
queryCollectionOptions({
14+
queryKey: ["products"], // Static key
15+
syncMode: "on-demand",
16+
queryFn: async (ctx) => {
17+
const { where, limit } = ctx.meta.loadSubsetOptions
18+
return fetch(`/api/products?...`).then((r) => r.json())
19+
},
20+
})
21+
```
22+
23+
With different live queries filtering by `category='A'` and `category='B'`, both would share the same cache key `['products']`, causing the last query to overwrite the first.
24+
25+
**After:**
26+
Static queryKeys now work correctly in on-demand mode! The system automatically creates unique cache keys:
27+
28+
- Query with `category='A'``['products', '{"where":{...A...}}']`
29+
- Query with `category='B'``['products', '{"where":{...B...}}']`
30+
31+
**Key behaviors:**
32+
33+
- ✅ Static queryKeys now work correctly with on-demand mode (automatic serialization)
34+
- ✅ Function-based queryKeys continue to work as before (no change)
35+
- ✅ Eager mode with static queryKeys unchanged (no automatic serialization)
36+
- ✅ Identical predicates correctly reuse the same cache entry
37+
38+
This makes the documentation example work correctly without requiring users to manually implement function-based queryKeys for predicate push-down.

packages/query-db-collection/src/query.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
QueryKeyRequiredError,
77
} from "./errors"
88
import { createWriteUtils } from "./manual-sync"
9+
import { serializeLoadSubsetOptions } from "./serialization"
910
import type {
1011
BaseCollectionConfig,
1112
ChangeMessage,
@@ -626,7 +627,19 @@ export function queryCollectionOptions(
626627
queryFunction: typeof queryFn = queryFn
627628
): true | Promise<void> => {
628629
// Push the predicates down to the queryKey and queryFn
629-
const key = typeof queryKey === `function` ? queryKey(opts) : queryKey
630+
let key: QueryKey
631+
if (typeof queryKey === `function`) {
632+
// Function-based queryKey: use it to build the key from opts
633+
key = queryKey(opts)
634+
} else if (syncMode === `on-demand`) {
635+
// Static queryKey in on-demand mode: automatically append serialized predicates
636+
// to create separate cache entries for different predicate combinations
637+
const serialized = serializeLoadSubsetOptions(opts)
638+
key = serialized !== undefined ? [...queryKey, serialized] : queryKey
639+
} else {
640+
// Static queryKey in eager mode: use as-is
641+
key = queryKey
642+
}
630643
const hashedQueryKey = hashKey(key)
631644
const extendedMeta = { ...meta, loadSubsetOptions: opts }
632645

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import type { IR, LoadSubsetOptions } from "@tanstack/db"
2+
3+
/**
4+
* Serializes LoadSubsetOptions into a stable, hashable format for query keys
5+
* @internal
6+
*/
7+
export function serializeLoadSubsetOptions(
8+
options: LoadSubsetOptions | undefined
9+
): string | undefined {
10+
if (!options) {
11+
return undefined
12+
}
13+
14+
const result: Record<string, unknown> = {}
15+
16+
if (options.where) {
17+
result.where = serializeExpression(options.where)
18+
}
19+
20+
if (options.orderBy?.length) {
21+
result.orderBy = options.orderBy.map((clause) => {
22+
const baseOrderBy = {
23+
expression: serializeExpression(clause.expression),
24+
direction: clause.compareOptions.direction,
25+
nulls: clause.compareOptions.nulls,
26+
stringSort: clause.compareOptions.stringSort,
27+
}
28+
29+
// Handle locale-specific options when stringSort is 'locale'
30+
if (clause.compareOptions.stringSort === `locale`) {
31+
return {
32+
...baseOrderBy,
33+
locale: clause.compareOptions.locale,
34+
localeOptions: clause.compareOptions.localeOptions,
35+
}
36+
}
37+
38+
return baseOrderBy
39+
})
40+
}
41+
42+
if (options.limit !== undefined) {
43+
result.limit = options.limit
44+
}
45+
46+
return Object.keys(result).length === 0 ? undefined : JSON.stringify(result)
47+
}
48+
49+
/**
50+
* Recursively serializes an IR expression for stable hashing
51+
* @internal
52+
*/
53+
function serializeExpression(expr: IR.BasicExpression | undefined): unknown {
54+
if (!expr) {
55+
return null
56+
}
57+
58+
switch (expr.type) {
59+
case `val`:
60+
return {
61+
type: `val`,
62+
value: serializeValue(expr.value),
63+
}
64+
case `ref`:
65+
return {
66+
type: `ref`,
67+
path: [...expr.path],
68+
}
69+
case `func`:
70+
return {
71+
type: `func`,
72+
name: expr.name,
73+
args: expr.args.map((arg) => serializeExpression(arg)),
74+
}
75+
default:
76+
return null
77+
}
78+
}
79+
80+
/**
81+
* Serializes special JavaScript values (undefined, NaN, Infinity, Date)
82+
* @internal
83+
*/
84+
function serializeValue(value: unknown): unknown {
85+
if (value === undefined) {
86+
return { __type: `undefined` }
87+
}
88+
89+
if (typeof value === `number`) {
90+
if (Number.isNaN(value)) {
91+
return { __type: `nan` }
92+
}
93+
if (value === Number.POSITIVE_INFINITY) {
94+
return { __type: `infinity`, sign: 1 }
95+
}
96+
if (value === Number.NEGATIVE_INFINITY) {
97+
return { __type: `infinity`, sign: -1 }
98+
}
99+
}
100+
101+
if (
102+
value === null ||
103+
typeof value === `string` ||
104+
typeof value === `number` ||
105+
typeof value === `boolean`
106+
) {
107+
return value
108+
}
109+
110+
if (value instanceof Date) {
111+
return { __type: `date`, value: value.toJSON() }
112+
}
113+
114+
if (Array.isArray(value)) {
115+
return value.map((item) => serializeValue(item))
116+
}
117+
118+
if (typeof value === `object`) {
119+
return Object.fromEntries(
120+
Object.entries(value as Record<string, unknown>).map(([key, val]) => [
121+
key,
122+
serializeValue(val),
123+
])
124+
)
125+
}
126+
127+
return value
128+
}

packages/query-db-collection/tests/query.test.ts

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3667,4 +3667,180 @@ describe(`QueryCollection`, () => {
36673667
expect(collection.size).toBe(0)
36683668
})
36693669
})
3670+
3671+
describe(`Static queryKey with on-demand mode`, () => {
3672+
it(`should automatically append serialized predicates to static queryKey in on-demand mode`, async () => {
3673+
const items: Array<CategorisedItem> = [
3674+
{ id: `1`, name: `Item 1`, category: `A` },
3675+
{ id: `2`, name: `Item 2`, category: `A` },
3676+
{ id: `3`, name: `Item 3`, category: `B` },
3677+
{ id: `4`, name: `Item 4`, category: `B` },
3678+
]
3679+
3680+
const queryFn = vi.fn((ctx: QueryFunctionContext) => {
3681+
const loadSubsetOptions = ctx.meta?.loadSubsetOptions as
3682+
| LoadSubsetOptions
3683+
| undefined
3684+
// Filter items based on the where clause if present
3685+
if (loadSubsetOptions?.where) {
3686+
// Simple mock filtering - in real use, you'd use parseLoadSubsetOptions
3687+
return Promise.resolve(items)
3688+
}
3689+
return Promise.resolve(items)
3690+
})
3691+
3692+
const staticQueryKey = [`static-on-demand-test`]
3693+
3694+
const config: QueryCollectionConfig<CategorisedItem> = {
3695+
id: `static-on-demand-test`,
3696+
queryClient,
3697+
queryKey: staticQueryKey, // Static queryKey (not a function)
3698+
queryFn,
3699+
getKey: (item: CategorisedItem) => item.id,
3700+
syncMode: `on-demand`,
3701+
startSync: true,
3702+
}
3703+
3704+
const options = queryCollectionOptions(config)
3705+
const collection = createCollection(options)
3706+
3707+
// Collection should start empty with on-demand sync mode
3708+
expect(collection.size).toBe(0)
3709+
3710+
// Create first live query with category A filter
3711+
const queryA = createLiveQueryCollection({
3712+
query: (q) =>
3713+
q
3714+
.from({ item: collection })
3715+
.where(({ item }) => eq(item.category, `A`))
3716+
.select(({ item }) => item),
3717+
})
3718+
3719+
await queryA.preload()
3720+
3721+
// Wait for first query to load
3722+
await vi.waitFor(() => {
3723+
expect(collection.size).toBeGreaterThan(0)
3724+
})
3725+
3726+
// Verify queryFn was called
3727+
expect(queryFn).toHaveBeenCalledTimes(1)
3728+
const firstCall = queryFn.mock.calls[0]?.[0]
3729+
expect(firstCall?.meta?.loadSubsetOptions).toBeDefined()
3730+
3731+
// Create second live query with category B filter
3732+
const queryB = createLiveQueryCollection({
3733+
query: (q) =>
3734+
q
3735+
.from({ item: collection })
3736+
.where(({ item }) => eq(item.category, `B`))
3737+
.select(({ item }) => item),
3738+
})
3739+
3740+
await queryB.preload()
3741+
3742+
// Wait for second query to trigger another queryFn call
3743+
await vi.waitFor(() => {
3744+
expect(queryFn).toHaveBeenCalledTimes(2)
3745+
})
3746+
3747+
// Verify the second call has different loadSubsetOptions
3748+
const secondCall = queryFn.mock.calls[1]?.[0]
3749+
expect(secondCall?.meta?.loadSubsetOptions).toBeDefined()
3750+
3751+
// The two queries should have triggered separate cache entries
3752+
// because the static queryKey was automatically extended with serialized predicates
3753+
expect(queryFn).toHaveBeenCalledTimes(2)
3754+
3755+
// Cleanup
3756+
await queryA.cleanup()
3757+
await queryB.cleanup()
3758+
})
3759+
3760+
it(`should create same cache key for identical predicates with static queryKey`, async () => {
3761+
const items: Array<CategorisedItem> = [
3762+
{ id: `1`, name: `Item 1`, category: `A` },
3763+
{ id: `2`, name: `Item 2`, category: `A` },
3764+
]
3765+
3766+
const queryFn = vi.fn().mockResolvedValue(items)
3767+
3768+
const config: QueryCollectionConfig<CategorisedItem> = {
3769+
id: `static-identical-predicates-test`,
3770+
queryClient,
3771+
queryKey: [`identical-test`],
3772+
queryFn,
3773+
getKey: (item: CategorisedItem) => item.id,
3774+
syncMode: `on-demand`,
3775+
startSync: true,
3776+
}
3777+
3778+
const options = queryCollectionOptions(config)
3779+
const collection = createCollection(options)
3780+
3781+
// Create two live queries with identical predicates
3782+
const query1 = createLiveQueryCollection({
3783+
query: (q) =>
3784+
q
3785+
.from({ item: collection })
3786+
.where(({ item }) => eq(item.category, `A`))
3787+
.select(({ item }) => item),
3788+
})
3789+
3790+
const query2 = createLiveQueryCollection({
3791+
query: (q) =>
3792+
q
3793+
.from({ item: collection })
3794+
.where(({ item }) => eq(item.category, `A`))
3795+
.select(({ item }) => item),
3796+
})
3797+
3798+
await query1.preload()
3799+
await query2.preload()
3800+
3801+
await vi.waitFor(() => {
3802+
expect(collection.size).toBeGreaterThan(0)
3803+
})
3804+
3805+
// Should only call queryFn once because identical predicates
3806+
// should produce the same serialized cache key
3807+
expect(queryFn).toHaveBeenCalledTimes(1)
3808+
3809+
// Cleanup
3810+
await query1.cleanup()
3811+
await query2.cleanup()
3812+
})
3813+
3814+
it(`should work correctly in eager mode with static queryKey (no automatic serialization)`, async () => {
3815+
const items: Array<TestItem> = [
3816+
{ id: `1`, name: `Item 1` },
3817+
{ id: `2`, name: `Item 2` },
3818+
]
3819+
3820+
const queryFn = vi.fn().mockResolvedValue(items)
3821+
3822+
const config: QueryCollectionConfig<TestItem> = {
3823+
id: `static-eager-test`,
3824+
queryClient,
3825+
queryKey: [`eager-test`],
3826+
queryFn,
3827+
getKey,
3828+
syncMode: `eager`, // Eager mode should NOT append predicates
3829+
startSync: true,
3830+
}
3831+
3832+
const options = queryCollectionOptions(config)
3833+
const collection = createCollection(options)
3834+
3835+
// Wait for initial load
3836+
await vi.waitFor(() => {
3837+
expect(collection.size).toBe(items.length)
3838+
})
3839+
3840+
// Should call queryFn once with empty predicates
3841+
expect(queryFn).toHaveBeenCalledTimes(1)
3842+
const call = queryFn.mock.calls[0]?.[0]
3843+
expect(call?.meta?.loadSubsetOptions).toEqual({})
3844+
})
3845+
})
36703846
})

0 commit comments

Comments
 (0)